Files
claude-code/docs/superpowers/plans/2026-06-20-artifacts-feature.md
2026-06-20 14:25:47 +08:00

43 KiB
Raw Blame History

Artifacts Feature Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an ArtifactTool (deferred) that uploads local HTML to cloud-artifacts service and returns a public URL + hash, a /artifacts panel command to browse uploaded files in the current session, and a /use-artifacts bundled skill that teaches the agent when/how to use artifacts.

Architecture: Tool is a deferred @claude-code-best/builtin-tools entry that wraps a fetch-based HTTP client; the client reads token/URL from hardcoded defaults with env var override, parses the {error} field in body for failure detection (Deno Deploy proxy flattens HTTP status to 200). The panel command is a local-jsx slash command that scans context.messages for artifact tool_use + tool_result pairs. The skill is a bundled skill that injects guidance on artifact types, cadence, and the two-step SearchExtraTools + ExecuteExtraTool invocation flow.

Tech Stack: Bun, TypeScript strict, Zod v4 (zod/v4), React (Ink via packages/@ant/ink), bun:test.


File Structure

File Responsibility
packages/builtin-tools/src/tools/ArtifactTool/config.ts Token/URL constants + env var override helpers
packages/builtin-tools/src/tools/ArtifactTool/client.ts uploadArtifact() — HTTP POST to cloud-artifacts, body error parsing
packages/builtin-tools/src/tools/ArtifactTool/prompt.ts ARTIFACT_TOOL_NAME, async description() / prompt()
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts buildTool() definition: schema, call, render, map
packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts client unit tests (mock fetch)
packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts Tool end-to-end tests (real temp file + mock fetch)
packages/builtin-tools/src/index.ts Barrel export for ArtifactTool (modify)
src/tools.ts Register ArtifactTool in the tools array (modify)
src/skills/bundled/useArtifacts.ts Bundled skill body + registerUseArtifactsSkill()
src/skills/bundled/index.ts Call registerUseArtifactsSkill() in initBundledSkills() (modify)
src/commands/artifacts/scanner.ts Pure extractArtifacts(messages) — scan tool_use/tool_result pairs
src/commands/artifacts/__tests__/scanner.test.ts scanner unit tests
src/commands/artifacts/ArtifactsMenu.tsx React/Ink list component with Enter/c/Esc
src/commands/artifacts/artifacts.tsx call(onDone, context) entry — calls scanner, renders menu
src/commands/artifacts/index.ts satisfies Command definition with lazy load
src/commands.ts Register artifacts command (modify)

Task 1: ArtifactTool config (token/URL defaults)

Files:

  • Create: packages/builtin-tools/src/tools/ArtifactTool/config.ts

  • Step 1: Write config file

// packages/builtin-tools/src/tools/ArtifactTool/config.ts
/**
 * Cloud Artifacts service configuration.
 * Token/URL have hardcoded production defaults; env vars override for self-hosted deployments.
 */
export const ARTIFACTS_DEFAULT_TOKEN = 'claude-code-best'
export const ARTIFACTS_DEFAULT_URL = 'https://cloud-artifacts.claude-code-best.win'

export function getArtifactsToken(): string {
  return process.env.CLAUDE_ARTIFACTS_TOKEN ?? ARTIFACTS_DEFAULT_TOKEN
}

export function getArtifactsBaseUrl(): string {
  return process.env.CLAUDE_ARTIFACTS_URL ?? ARTIFACTS_DEFAULT_URL
}

/** Strip trailing slash so `${base}/upload` is well-formed. */
export function getUploadUrl(): string {
  const base = getArtifactsBaseUrl()
  return base.endsWith('/') ? `${base}upload` : `${base}/upload`
}
  • Step 2: Commit
git add packages/builtin-tools/src/tools/ArtifactTool/config.ts
git commit -m "feat(artifact): add cloud-artifacts config with token/URL defaults"

Task 2: ArtifactTool client (TDD — uploadArtifact)

Files:

  • Create: packages/builtin-tools/src/tools/ArtifactTool/client.ts

  • Test: packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts

  • Step 1: Write the failing test

// packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { uploadArtifact } from '../client.js'

const originalFetch = globalThis.fetch

function mockFetch(body: object, status = 200): typeof fetch {
  return mock((_url: string | URL | Request, _init?: RequestInit) =>
    Promise.resolve(
      new Response(JSON.stringify(body), {
        status,
        headers: { 'Content-Type': 'application/json' },
      }),
    ),
  ) as unknown as typeof fetch
}

describe('uploadArtifact', () => {
  afterEach(() => {
    globalThis.fetch = originalFetch
  })

  test('returns id/url/expiresAt on successful upload', async () => {
    globalThis.fetch = mockFetch({
      id: 'V1StGXR8_Z5jdHi6B',
      url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
    })

    const result = await uploadArtifact({
      html: '<h1>hello</h1>',
      token: 'test-token',
      uploadUrl: 'https://example.test/upload',
    })

    expect(result).toEqual({
      id: 'V1StGXR8_Z5jdHi6B',
      url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
    })
  })

  test('passes hash as query param when provided', async () => {
    const fetchMock = mockFetch({ id: 'my-id', url: 'https://x/y.html', expiresAt: '2026-06-27T00:00:00.000Z' })
    globalThis.fetch = fetchMock

    await uploadArtifact({
      html: '<p>x</p>',
      token: 't',
      uploadUrl: 'https://example.test/upload',
      hash: 'my-id',
    })

    const calledUrl = (fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }).mock.calls[0][0]
    expect(calledUrl.toString()).toContain('hash=my-id')
  })

  test('passes ttl=30 query param when provided', async () => {
    const fetchMock = mockFetch({ id: 'x', url: 'https://x', expiresAt: '2026-07-20T00:00:00.000Z' })
    globalThis.fetch = fetchMock

    await uploadArtifact({
      html: '<p>x</p>',
      token: 't',
      uploadUrl: 'https://example.test/upload',
      ttl: 30,
    })

    const calledUrl = (fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }).mock.calls[0][0]
    expect(calledUrl.toString()).toContain('ttl=30')
  })

  test('throws with error code when body contains {error} (Deno Deploy flattens status)', async () => {
    globalThis.fetch = mockFetch({ error: 'payload_too_large' }, 200)

    await expect(
      uploadArtifact({
        html: 'x'.repeat(100),
        token: 't',
        uploadUrl: 'https://example.test/upload',
      }),
    ).rejects.toThrow(/payload_too_large/)
  })

  test('throws on non-JSON body', async () => {
    globalThis.fetch = mock((_u: string | URL | Request) =>
      Promise.resolve(new Response('Internal Server Error', { status: 500 })),
    ) as unknown as typeof fetch

    await expect(
      uploadArtifact({ html: '<p/>', token: 't', uploadUrl: 'https://example.test/upload' }),
    ).rejects.toThrow()
  })
})
  • Step 2: Run test to verify it fails
bun test packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts

Expected: FAIL with Cannot find module '../client.js' or similar.

  • Step 3: Implement the client
// packages/builtin-tools/src/tools/ArtifactTool/client.ts
export type UploadResult = {
  id: string
  url: string
  expiresAt: string
}

export type UploadParams = {
  html: string
  token: string
  uploadUrl: string
  hash?: string
  ttl?: 7 | 30
}

export async function uploadArtifact(params: UploadParams): Promise<UploadResult> {
  const url = new URL(params.uploadUrl)
  if (params.hash) url.searchParams.set('hash', params.hash)
  if (params.ttl) url.searchParams.set('ttl', String(params.ttl))

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${params.token}`,
      'Content-Type': 'text/html',
    },
    body: params.html,
  })

  // Deno Deploy proxy flattens upstream status to 200; the Worker embeds the
  // real error in the body as `{ "error": "<code>" }`. Always parse body first.
  const text = await response.text()
  let parsed: unknown
  try {
    parsed = JSON.parse(text)
  } catch {
    throw new Error(`Artifact upload failed: HTTP ${response.status} (non-JSON body)`)
  }

  if (parsed && typeof parsed === 'object' && 'error' in parsed) {
    const code = (parsed as { error: unknown }).error
    throw new Error(`Artifact upload failed: ${String(code)}`)
  }

  const data = parsed as Partial<UploadResult>
  if (typeof data.id !== 'string' || typeof data.url !== 'string' || typeof data.expiresAt !== 'string') {
    throw new Error(`Artifact upload returned malformed body: ${text.slice(0, 200)}`)
  }
  return { id: data.id, url: data.url, expiresAt: data.expiresAt }
}
  • Step 4: Run tests to verify they pass
bun test packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts

Expected: PASS (5 tests).

  • Step 5: Commit
git add packages/builtin-tools/src/tools/ArtifactTool/client.ts packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts
git commit -m "feat(artifact): add HTTP client with body-error parsing"

Task 3: ArtifactTool prompt (name + description)

Files:

  • Create: packages/builtin-tools/src/tools/ArtifactTool/prompt.ts

  • Step 1: Write prompt file

// packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
export const ARTIFACT_TOOL_NAME = 'artifact'

export async function describeArtifactTool(): Promise<string> {
  return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).'
}

export async function getArtifactToolPrompt(): Promise<string> {
  return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash").

## Inputs
- \`file_path\` (required): absolute path to a local HTML file.
- \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated.
- \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`.

## Output
\`{ id, url, expiresAt }\` — \`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible.

## Workflow
1. Use the Write tool to create a local HTML file.
2. Call this tool with its \`file_path\`.
3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable.

## Errors
The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.`
}
  • Step 2: Commit
git add packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
git commit -m "feat(artifact): add tool name, description, and prompt"

Task 4: ArtifactTool definition (schema + call + render + map)

Files:

  • Create: packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts

  • Step 1: Write the tool definition

// packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
import { stat, readFile } from 'fs/promises'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { ARTIFACT_TOOL_NAME, describeArtifactTool, getArtifactToolPrompt } from './prompt.js'
import { getArtifactsToken, getUploadUrl } from './config.js'
import { uploadArtifact } from './client.js'

const inputSchema = lazySchema(() =>
  z.strictObject({
    file_path: z.string().describe('Absolute path to a local HTML file to upload.'),
    hash: z
      .string()
      .regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
      .optional()
      .describe('If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.'),
    ttl: z.union([z.literal(7), z.literal(30)]).default(7).describe('Lifetime in days. Must be 7 or 30. Default 7.'),
  }),
)
type InputSchema = ReturnType<typeof inputSchema>
type ArtifactInput = z.infer<InputSchema>

const outputSchema = lazySchema(() =>
  z.object({
    id: z.string(),
    url: z.string(),
    expiresAt: z.string(),
  }),
)
type OutputSchema = ReturnType<typeof outputSchema>
type ArtifactOutput = z.infer<OutputSchema>
type ArtifactErrorOutput = ArtifactOutput & { error?: string }

export const ArtifactTool = buildTool({
  name: ARTIFACT_TOOL_NAME,
  searchHint: 'upload html artifact share url cloud publish progress report public link',
  maxResultSizeChars: 2_000,
  shouldDefer: true,
  strict: true,

  get inputSchema(): InputSchema {
    return inputSchema()
  },
  get outputSchema(): OutputSchema {
    return outputSchema()
  },

  async description() {
    return describeArtifactTool()
  },
  async prompt() {
    return getArtifactToolPrompt()
  },

  isEnabled() {
    return true
  },
  isConcurrencySafe() {
    return false
  },
  isReadOnly() {
    return false
  },
  requiresUserInteraction() {
    return true
  },
  userFacingName() {
    return 'Artifact'
  },

  renderToolUseMessage(input: Partial<ArtifactInput>) {
    const hashPart = input.hash ? ` (hash=${input.hash})` : ''
    return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
  },

  mapToolResultToToolResultBlockParam(content: ArtifactErrorOutput, toolUseID: string): ToolResultBlockParam {
    if (content.error) {
      return {
        tool_use_id: toolUseID,
        type: 'tool_result',
        is_error: true,
        content: content.error,
      }
    }
    return {
      tool_use_id: toolUseID,
      type: 'tool_result',
      content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
    }
  },

  async call(input: ArtifactInput) {
    const { file_path, hash, ttl } = input

    let size: number
    try {
      const fileStat = await stat(file_path)
      if (!fileStat.isFile()) {
        return { data: { id: '', url: '', expiresAt: '', error: `Path is not a regular file: ${file_path}` } }
      }
      size = fileStat.size
    } catch {
      return { data: { id: '', url: '', expiresAt: '', error: `File does not exist or is not readable: ${file_path}` } }
    }

    if (size > 10 * 1024 * 1024) {
      return { data: { id: '', url: '', expiresAt: '', error: `File is ${size} bytes; backend limit is 10MB.` } }
    }

    let html: string
    try {
      html = await readFile(file_path, 'utf8')
    } catch {
      return { data: { id: '', url: '', expiresAt: '', error: `Failed to read file: ${file_path}` } }
    }

    try {
      const result = await uploadArtifact({
        html,
        token: getArtifactsToken(),
        uploadUrl: getUploadUrl(),
        hash,
        ttl,
      })
      return { data: result }
    } catch (e) {
      const message = e instanceof Error ? e.message : String(e)
      return { data: { id: '', url: '', expiresAt: '', error: message } }
    }
  },
})
  • Step 2: Commit
git add packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
git commit -m "feat(artifact): add buildTool definition with file validation"

Task 5: Tool end-to-end tests

Files:

  • Test: packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts

  • Step 1: Write the e2e tool test

// packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { ArtifactTool } from '../ArtifactTool.js'

const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
const TEST_FILE = join(TEST_DIR, 'report.html')
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
const DIR_AS_FILE = TEST_DIR

const originalFetch = globalThis.fetch

function mockFetchSuccess(body: object): typeof fetch {
  return mock(() =>
    Promise.resolve(
      new Response(JSON.stringify(body), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      }),
    ),
  ) as unknown as typeof fetch
}

describe('ArtifactTool.call', () => {
  beforeEach(() => {
    mkdirSync(TEST_DIR, { recursive: true })
    writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
    process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
    process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
  })

  afterEach(() => {
    if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
    delete process.env.CLAUDE_ARTIFACTS_TOKEN
    delete process.env.CLAUDE_ARTIFACTS_URL
    globalThis.fetch = originalFetch
  })

  test('uploads existing HTML file and returns id/url/expiresAt', async () => {
    globalThis.fetch = mockFetchSuccess({
      id: 'abc123',
      url: 'https://example.test/7d/abc123.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
    })

    const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 }, {} as never, {} as never, {} as never)

    expect(result.data).toMatchObject({
      id: 'abc123',
      url: 'https://example.test/7d/abc123.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
    })
    expect((result.data as { error?: string }).error).toBeUndefined()
  })

  test('passes hash through when overwriting', async () => {
    const fetchMock = mockFetchSuccess({
      id: 'stable-id',
      url: 'https://example.test/7d/stable-id.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
    })
    globalThis.fetch = fetchMock

    await ArtifactTool.call({ file_path: TEST_FILE, hash: 'stable-id', ttl: 7 }, {} as never, {} as never, {} as never)

    const calledUrl = (fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }).mock.calls[0][0]
    expect(calledUrl.toString()).toContain('hash=stable-id')
  })

  test('returns error when file does not exist (no HTTP call)', async () => {
    let fetchCalled = false
    globalThis.fetch = mock(() => {
      fetchCalled = true
      return Promise.resolve(new Response('{}'))
    }) as unknown as typeof fetch

    const result = await ArtifactTool.call({ file_path: MISSING_FILE, ttl: 7 }, {} as never, {} as never, {} as never)

    expect(fetchCalled).toBe(false)
    expect((result.data as { error?: string }).error).toContain('does not exist')
  })

  test('returns error when path is a directory', async () => {
    const result = await ArtifactTool.call({ file_path: DIR_AS_FILE, ttl: 7 }, {} as never, {} as never, {} as never)

    expect((result.data as { error?: string }).error).toContain('not a regular file')
  })

  test('returns error verbatim when backend rejects', async () => {
    globalThis.fetch = mock(() =>
      Promise.resolve(new Response(JSON.stringify({ error: 'payload_too_large' }), { status: 200 })),
    ) as unknown as typeof fetch

    // Force the size guard to pass by writing a small file but having backend complain.
    const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 }, {} as never, {} as never, {} as never)

    expect((result.data as { error?: string }).error).toContain('payload_too_large')
  })
})
  • Step 2: Run the e2e test
bun test packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts

Expected: PASS (5 tests).

  • Step 3: Commit
git add packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts
git commit -m "test(artifact): add end-to-end tool tests for upload/error paths"

Task 6: Export ArtifactTool from builtin-tools barrel

Files:

  • Modify: packages/builtin-tools/src/index.ts

  • Step 1: Read barrel to find an insertion point

grep -n "SendUserFile" packages/builtin-tools/src/index.ts
  • Step 2: Add the export (insert after the SendUserFileTool line, keep alphabetical/grouped ordering)

Add this single line next to the other tool exports:

export { ArtifactTool } from './tools/ArtifactTool/ArtifactTool.js'
  • Step 3: Verify export works
bun -e "import('@claude-code-best/builtin-tools').then(m => console.log(typeof m.ArtifactTool))"

Expected output: object (the built tool).

  • Step 4: Commit
git add packages/builtin-tools/src/index.ts
git commit -m "feat(artifact): export ArtifactTool from builtin-tools barrel"

Task 7: Register ArtifactTool in src/tools.ts

Files:

  • Modify: src/tools.ts

  • Step 1: Add the require import (place near other non-feature-gated tools)

Find a clean spot in the top section (near other const X = require(...) declarations) and add:

const ArtifactTool = require('@claude-code-best/builtin-tools/tools/ArtifactTool/ArtifactTool.js').ArtifactTool
  • Step 2: Spread into the tools array (find the main returned array and add ArtifactTool unconditionally)

Add ArtifactTool, to the array literal that returns the assembled tool list (e.g. next to BriefTool,).

  • Step 3: Verify by importing
bun -e "import('./src/tools.js').then(m => { const t = (m.getTools ?? m.tools); const arr = typeof t === 'function' ? t({mode:'default',additionalWorkingDirectories:new Set(),alwaysAllowRules:{deny:[],allow:[]},alwaysDenyRules:{deny:[],allow:[]},alwaysAskRules:{deny:[],allow:[]},isBypassPermissionsModeAvailable:false}) : t; console.log(arr.map(x=>x.name).includes('artifact')) })"

If the dynamic shape is hard to invoke, instead just typecheck:

bunx tsc --noEmit -p tsconfig.json 2>&1 | head -50

Expected: no new errors mentioning ArtifactTool.

  • Step 4: Commit
git add src/tools.ts
git commit -m "feat(artifact): register ArtifactTool in tools list"

Task 8: /use-artifacts bundled skill

Files:

  • Create: src/skills/bundled/useArtifacts.ts

  • Modify: src/skills/bundled/index.ts

  • Step 1: Write the skill file

// src/skills/bundled/useArtifacts.ts
import { registerBundledSkill } from '../bundledSkills.js'

const USE_ARTIFACTS_PROMPT = `# Using Artifacts

Artifacts are public HTML pages you upload to a hosting service. They have stable URLs that you can share with the user or open in a browser. Use them to surface work-in-progress, summaries, and reports.

## When to use artifacts

**Good artifact content:**
- Progress panels / kanbans (task list with status)
- Research reports and analysis (data + findings + recommendations)
- Design docs / decision records (with context and rationale)
- Data visualizations (tables, SVG charts, flow diagrams)
- Final deliverables (the "thing the user asked for" rendered as HTML)

**Do NOT use artifacts for:**
- Code snippets — use files directly
- One-line answers — keep them in chat
- Internal debug logs — keep them in chat
- Large data dumps — link to source files instead

## Cadence — when to upload

- **Task start**: if the task is complex (multi-step, research, deliverable), upload a skeleton artifact first as scaffolding (placeholder sections).
- **Milestones**: when you complete a phase (research done / implementation done / tests pass), update the artifact.
- **User asks**: upload immediately.
- **Task end**: ship the final artifact as the deliverable.

**Do NOT upload:**
- After every tool call (noise)
- Mid-step with no meaningful change (e.g. fixed a typo)

## How to invoke (deferred tool)

\`artifact\` is a deferred tool. The first call requires two steps; subsequent calls one step.

**First upload (creates a new artifact):**
\`\`\`
1. Use the Write tool to write HTML to a local file (location is your choice).
2. SearchExtraTools({ query: "select:artifact" })   // loads the tool schema
3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html" } })
4. Save the returned \`id\` from the tool result — this is the hash.
\`\`\`

**Subsequent updates (overwrites in place, URL stays stable):**
\`\`\`
1. Update the local HTML file.
2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html", hash: "<id-from-first-call>" } })
\`\`\`

The URL returned on every call is the same when you pass the same \`hash\`. The user can open it at any time to see the latest version.

## Minimal HTML skeleton

\`\`\`html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Artifact Title</title>
  <style>
    body { font: 14px/1.5 -apple-system, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
    h1, h2 { color: #1a1a1a; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
  </style>
</head>
<body>
  <h1>Artifact Title</h1>
  <!-- content here -->
</body>
</html>
\`\`\`

The hosting service serves the HTML verbatim (including any \`<script>\` you include), so you can use vanilla JS/SVG/CSS as needed. Do not embed secrets.

## Notes

- Artifacts expire (default 7 days; pass \`ttl: 30\` for 30-day retention).
- Anyone with the URL can view the artifact — treat the URL as the secret.
- The \`/artifacts\` slash command (user-invoked) shows all artifacts uploaded in the current session.
`

export function registerUseArtifactsSkill(): void {
  registerBundledSkill({
    name: 'use-artifacts',
    description:
      'Teach the agent when and how to use the artifact tool: what content belongs in artifacts, when to upload/update, and the SearchExtraTools + ExecuteExtraTool invocation flow for the deferred artifact tool.',
    whenToUse:
      'Use this skill at the start of any complex task that would benefit from a living progress document or a deliverable HTML report.',
    userInvocable: true,
    argumentHint: '[optional focus note]',
    async getPromptForCommand(args) {
      let prompt = USE_ARTIFACTS_PROMPT
      if (args && args.trim().length > 0) {
        prompt += `\n\n## Additional Focus\n\n${args.trim()}\n`
      }
      return [{ type: 'text', text: prompt }]
    },
  })
}
  • Step 2: Register in src/skills/bundled/index.ts

Add the import near the other bundled skill imports:

import { registerUseArtifactsSkill } from './useArtifacts.js'

Call the register function inside initBundledSkills() (place near registerSimplifySkill() for ordering consistency):

registerUseArtifactsSkill()
  • Step 3: Verify the skill registers
bun -e "import('./src/skills/bundled/index.js').then(m => { m.initBundledSkills(); import('./src/skills/bundledSkills.js').then(s => { const list = s.bundledSkills ?? s.getBundledSkills?.() ?? []; console.log(Array.isArray(list) ? list.map(c => c.name).filter(n => n === 'use-artifacts') : 'no list'); }) })"

If the runtime shape is awkward to introspect, fall back to typecheck only:

bunx tsc --noEmit -p tsconfig.json 2>&1 | grep -i artifact

Expected: no errors mentioning useArtifacts or use-artifacts.

  • Step 4: Commit
git add src/skills/bundled/useArtifacts.ts src/skills/bundled/index.ts
git commit -m "feat(artifact): add /use-artifacts bundled skill"

Task 9: /artifacts scanner (TDD — extractArtifacts)

Files:

  • Create: src/commands/artifacts/scanner.ts

  • Test: src/commands/artifacts/__tests__/scanner.test.ts

  • Step 1: Write the failing test

// src/commands/artifacts/__tests__/scanner.test.ts
import { describe, expect, test } from 'bun:test'
import { extractArtifacts } from '../scanner.js'
import type { Message } from 'src/types/message.js'

function assistantToolUse(id: string, input: Record<string, unknown>): Message {
  return {
    role: 'assistant',
    content: [{ type: 'tool_use', id, name: 'artifact', input }],
  } as unknown as Message
}

function userToolResult(id: string, content: string, isError = false): Message {
  return {
    role: 'user',
    content: [{ type: 'tool_result', tool_use_id: id, content, is_error: isError }],
  } as unknown as Message
}

describe('extractArtifacts', () => {
  test('returns empty list when no artifact tool_use messages', () => {
    expect(extractArtifacts([])).toEqual([])
    expect(extractArtifacts([{ role: 'user', content: [{ type: 'text', text: 'hi' }] } as unknown as Message])).toEqual([])
  })

  test('pairs a successful tool_use with its tool_result and returns parsed fields', () => {
    const messages: Message[] = [
      assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
      userToolResult('tu1', 'Artifact uploaded: https://x.test/7d/abc.html (id: abc, expires: 2026-06-27T10:00:00.000Z)'),
    ]

    const result = extractArtifacts(messages)

    expect(result).toHaveLength(1)
    expect(result[0]).toMatchObject({
      filePath: '/tmp/report.html',
      hash: 'abc',
      url: 'https://x.test/7d/abc.html',
      expiresAt: '2026-06-27T10:00:00.000Z',
      basename: 'report.html',
      isError: false,
    })
  })

  test('skips artifact tool_use without a matching tool_result', () => {
    const messages: Message[] = [assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 })]

    expect(extractArtifacts(messages)).toEqual([])
  })

  test('keeps error results with isError=true and no parsed fields', () => {
    const messages: Message[] = [
      assistantToolUse('tu1', { file_path: '/tmp/missing.html', ttl: 7 }),
      userToolResult('tu1', 'File does not exist or is not readable: /tmp/missing.html', true),
    ]

    const result = extractArtifacts(messages)

    expect(result).toHaveLength(1)
    expect(result[0]).toMatchObject({
      filePath: '/tmp/missing.html',
      basename: 'missing.html',
      isError: true,
    })
    expect(result[0].url).toBeUndefined()
  })

  test('orders newest first (last in conversation appears at top)', () => {
    const messages: Message[] = [
      assistantToolUse('tu1', { file_path: '/tmp/a.html', ttl: 7 }),
      userToolResult('tu1', 'Artifact uploaded: https://x.test/7d/a.html (id: a, expires: 2026-06-27T10:00:00.000Z)'),
      assistantToolUse('tu2', { file_path: '/tmp/b.html', ttl: 7 }),
      userToolResult('tu2', 'Artifact uploaded: https://x.test/7d/b.html (id: b, expires: 2026-06-27T10:00:00.000Z)'),
    ]

    const result = extractArtifacts(messages)

    expect(result.map((r) => r.basename)).toEqual(['b.html', 'a.html'])
  })
})
  • Step 2: Run the test to verify it fails
bun test src/commands/artifacts/__tests__/scanner.test.ts

Expected: FAIL with Cannot find module '../scanner.js'.

  • Step 3: Implement scanner.ts
// src/commands/artifacts/scanner.ts
import { basename } from 'path'
import type { Message } from 'src/types/message.js'

export type ArtifactInfo = {
  toolUseId: string
  filePath: string
  basename: string
  hash?: string
  url?: string
  expiresAt?: string
  rawContent: string
  isError: boolean
}

const URL_REGEX = /https?:\/\/\S+\.html\b/
const ID_REGEX = /\bid:\s*([A-Za-z0-9_-]+)/
const EXPIRES_REGEX = /\bexpires:\s*([0-9T:.Z+-]+)/

export function extractArtifacts(messages: Message[]): ArtifactInfo[] {
  const results: ArtifactInfo[] = []

  for (const message of messages) {
    if (message.role !== 'assistant') continue
    const content = message.content
    if (!Array.isArray(content)) continue

    for (const block of content) {
      if (typeof block !== 'object' || block === null) continue
      if (block.type !== 'tool_use') continue
      if (block.name !== 'artifact') continue

      const toolUseId = block.id
      const input = block.input as { file_path?: string } | undefined
      const filePath = input?.file_path ?? '<unknown>'

      const resultBlock = findToolResult(messages, toolUseId)
      if (!resultBlock) continue

      const rawContent =
        typeof resultBlock.content === 'string'
          ? resultBlock.content
          : Array.isArray(resultBlock.content)
            ? resultBlock.content.map((c) => (typeof c === 'string' ? c : 'text' in c ? c.text : '')).join('')
            : ''

      const isError = resultBlock.is_error === true
      const urlMatch = rawContent.match(URL_REGEX)
      const idMatch = rawContent.match(ID_REGEX)
      const expiresMatch = rawContent.match(EXPIRES_REGEX)

      results.push({
        toolUseId,
        filePath,
        basename: basename(filePath),
        hash: idMatch?.[1],
        url: urlMatch?.[0],
        expiresAt: expiresMatch?.[1],
        rawContent,
        isError,
      })
    }
  }

  // newest first
  return results.reverse()
}

function findToolResult(
  messages: Message[],
  toolUseId: string,
): { content: unknown; is_error?: boolean } | null {
  for (const message of messages) {
    if (message.role !== 'user') continue
    const content = message.content
    if (!Array.isArray(content)) continue
    for (const block of content) {
      if (typeof block !== 'object' || block === null) continue
      if (block.type !== 'tool_result') continue
      if (block.tool_use_id !== toolUseId) continue
      return { content: block.content, is_error: block.is_error }
    }
  }
  return null
}
  • Step 4: Run the tests to verify they pass
bun test src/commands/artifacts/__tests__/scanner.test.ts

Expected: PASS (5 tests).

  • Step 5: Commit
git add src/commands/artifacts/scanner.ts src/commands/artifacts/__tests__/scanner.test.ts
git commit -m "feat(artifact): add extractArtifacts message scanner"

Task 10: /artifacts ArtifactsMenu component

Files:

  • Create: src/commands/artifacts/ArtifactsMenu.tsx

  • Step 1: Inspect an existing Ink list component for style

ls src/components/agents/

(Skim AgentsMenu.tsx for the onExit + keybinding pattern. Borrow only the structural shell — do not copy code that doesn't apply.)

  • Step 2: Write the component
// src/commands/artifacts/ArtifactsMenu.tsx
import * as React from 'react'
import { Box, Text, useInput } from '@ant/ink'
import type { ArtifactInfo } from './scanner.js'
import { openBrowser } from 'src/utils/browser.js'

type Props = {
  artifacts: ArtifactInfo[]
  onExit: () => void
}

export function ArtifactsMenu({ artifacts, onExit }: Props): React.ReactElement {
  const [selected, setSelected] = React.useState(0)

  useInput((input, key) => {
    if (input === 'q' || key.escape) {
      onExit()
      return
    }
    if (artifacts.length === 0) return
    if (key.upArrow) {
      setSelected((s) => (s - 1 + artifacts.length) % artifacts.length)
      return
    }
    if (key.downArrow) {
      setSelected((s) => (s + 1) % artifacts.length)
      return
    }
    if (key.return) {
      const target = artifacts[selected]
      if (target.url) {
        void openBrowser(target.url)
      }
      return
    }
    if (input === 'c') {
      const target = artifacts[selected]
      if (target.url) {
        copyToClipboard(target.url)
      }
    }
  })

  return (
    <Box flexDirection="column" paddingX={1} paddingY={0}>
      <Box marginBottom={1}>
        <Text bold>Artifacts ({artifacts.length})</Text>
      </Box>

      {artifacts.length === 0 ? (
        <Text color="gray">No artifacts uploaded this session. Run /use-artifacts to learn how.</Text>
      ) : (
        <Box flexDirection="column">
          {artifacts.map((a, idx) => (
            <ArtifactRow key={a.toolUseId} artifact={a} isSelected={idx === selected} />
          ))}
          <Box marginTop={1}>
            <Text color="gray">/ select · Enter open · c copy URL · Esc exit</Text>
          </Box>
        </Box>
      )}
    </Box>
  )
}

function ArtifactRow({ artifact, isSelected }: { artifact: ArtifactInfo; isSelected: boolean }): React.ReactElement {
  const marker = isSelected ? '' : ' '
  return (
    <Box flexDirection="column">
      <Box>
        <Text color={isSelected ? 'blue' : undefined}>{marker} </Text>
        <Text bold={isSelected} color={artifact.isError ? 'red' : undefined}>
          {artifact.basename}
        </Text>
        {artifact.hash ? <Text color="gray"> ({artifact.hash})</Text> : null}
      </Box>
      {artifact.url ? (
        <Box marginLeft={2}>
          <Text color="cyan">{artifact.url}</Text>
        </Box>
      ) : (
        <Box marginLeft={2}>
          <Text color="red">{artifact.rawContent}</Text>
        </Box>
      )}
      {artifact.expiresAt ? (
        <Box marginLeft={2}>
          <Text color="gray">expires: {artifact.expiresAt}</Text>
        </Box>
      ) : null}
    </Box>
  )
}

// macOS-only clipboard via pbcopy. The CLI is primarily macOS-targeted; on
// other platforms this is a no-op (URL is still rendered above for the user
// to select and copy manually).
function copyToClipboard(text: string): void {
  try {
    const { spawnSync } = require('node:child_process') as typeof import('node:child_process')
    spawnSync('pbcopy', [], { input: text })
  } catch {
    // best-effort
  }
}
  • Step 3: Commit
git add src/commands/artifacts/ArtifactsMenu.tsx
git commit -m "feat(artifact): add ArtifactsMenu Ink component"

Task 11: /artifacts command entry (artifacts.tsx + index.ts)

Files:

  • Create: src/commands/artifacts/artifacts.tsx

  • Create: src/commands/artifacts/index.ts

  • Step 1: Write the call() function

// src/commands/artifacts/artifacts.tsx
import * as React from 'react'
import type { LocalJSXCommandOnDone } from 'src/types/command.js'
import type { ToolUseContext } from 'src/Tool.js'
import { ArtifactsMenu } from './ArtifactsMenu.js'
import { extractArtifacts } from './scanner.js'

export async function call(
  onDone: LocalJSXCommandOnDone,
  context: ToolUseContext,
): Promise<React.ReactNode> {
  const messages = context.messages ?? []
  const artifacts = extractArtifacts(messages)
  return <ArtifactsMenu artifacts={artifacts} onExit={onDone} />
}
  • Step 2: Write the command definition
// src/commands/artifacts/index.ts
import type { Command } from '../../commands.js'

const artifacts = {
  type: 'local-jsx',
  name: 'artifacts',
  description: 'List HTML artifacts uploaded to cloud-artifacts in this session',
  isEnabled: () => true,
  userFacingName: () => 'Artifacts',
  load: () => import('./artifacts.js'),
} satisfies Command

export default artifacts
  • Step 3: Commit
git add src/commands/artifacts/artifacts.tsx src/commands/artifacts/index.ts
git commit -m "feat(artifact): add /artifacts slash command entry"

Task 12: Register /artifacts in src/commands.ts

Files:

  • Modify: src/commands.ts

  • Step 1: Add import near other command imports

import artifacts from './commands/artifacts/index.js'

(Place alphabetically — likely right after addDir or near agents.)

  • Step 2: Add to the COMMANDS array

Add artifacts, to the memoized COMMANDS() array (e.g. as the first entry, or after agents).

  • Step 3: Verify by typecheck
bunx tsc --noEmit -p tsconfig.json 2>&1 | grep -i artifact

Expected: no new errors mentioning artifacts.

  • Step 4: Commit
git add src/commands.ts
git commit -m "feat(artifact): register /artifacts command"

Task 13: Full precheck (typecheck + lint + test)

  • Step 1: Run the project precheck
bun run precheck

Expected: typecheck passes, lint fix applies cleanly, all tests pass (including the new artifact tests).

  • Step 2: Fix any issues that surface

If type errors mention artifact-related files, fix them. If lint complaints surface (e.g. import ordering, unused vars), apply the suggested fixes.

  • Step 3: Commit any fixes
git add -A
git commit -m "chore(artifact): precheck fixes"

(Skip this step if precheck was already clean.)


Task 14: Smoke test checklist (manual)

This task is a manual checklist — no automated test. After implementation, verify in a dev session:

  • Step 1: Start dev mode
bun run dev
  • Step 2: Verify /use-artifacts registers

In the REPL, type /use-artifacts and confirm it appears in the slash command autocomplete and executes (injects the prompt).

  • Step 3: Verify /artifacts empty state

Type /artifacts before any uploads. Confirm you see the empty-state message: "No artifacts uploaded this session. Run /use-artifacts to learn how."

  • Step 4: Verify tool discovery

In a conversation, prompt the agent: "Create an HTML file at /tmp/test-artifact.html with <h1>hello</h1> then upload it as an artifact."

Confirm the agent calls SearchExtraTools({ query: "select:artifact" }) then ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "/tmp/test-artifact.html" } }) and returns a URL.

  • Step 5: Verify /artifacts list after upload

After the agent uploads, type /artifacts again. Confirm you see the artifact row with the URL. Press Enter to open in browser (verifies the URL renders). Press c to copy to clipboard (verify pbcopy works on macOS). Press Esc to exit.

  • Step 6: Verify overwrite

Ask the agent: "Update the same artifact — change the HTML to <h1>updated</h1> and upload with the same hash." Confirm the second call uses hash and returns the same URL.

  • Step 7: Document any rough edges

If anything in steps 2-6 fails, file a follow-up. Otherwise no commit needed.


Self-Review

Spec coverage check:

  • ArtifactTool (deferred, filepath input, returns url + hash) — Tasks 1-5
  • Overwrite mechanism via hash param — Task 4 (schema + call), Task 2 (client passes hash)
  • /artifacts panel shows uploaded files — Tasks 9-12
  • /use-artifacts builtin skill teaches workflow — Task 8
  • Token/URL config (hardcoded default + env override) — Task 1
  • Error handling (stat precheck, body error parsing, no retry) — Tasks 2, 4
  • Permission (always ask) — Task 4 (requiresUserInteraction: () => true)
  • Panel UX (Enter open, c copy, Esc exit, empty state) — Task 10
  • Session-scoped data source — Task 9 (extractArtifacts(messages))

Placeholder scan: No TBD/TODO/"implement later". All code blocks contain complete, runnable code. No "similar to Task N" references.

Type consistency:

  • ArtifactInput / ArtifactOutput defined in Task 4, used consistently.
  • UploadParams / UploadResult defined in Task 2, used in Task 4's call().
  • ArtifactInfo defined in Task 9 (scanner), imported by Task 10 (ArtifactsMenu) and Task 11 (artifacts.tsx). Field names (filePath, basename, hash, url, expiresAt, rawContent, isError) match across all consumers.
  • Tool name constant ARTIFACT_TOOL_NAME = 'artifact' defined in Task 3, used in Task 4. Same string literal 'artifact' used in Task 9 scanner filter.
  • Skill name 'use-artifacts' defined in Task 8.
  • Command name 'artifacts' defined in Task 11.

Gaps:

  • React component testing is intentionally omitted (the codebase has minimal React/Ink component tests; the pure scanner logic in Task 9 carries the testable burden).
  • Cross-platform clipboard in Task 10 is macOS-only (pbcopy) — documented as a best-effort no-op on other platforms.