# 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** ```typescript // 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** ```bash 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** ```typescript // 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: '
x
', 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: 'x
', 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: '', token: 't', uploadUrl: 'https://example.test/upload' }), ).rejects.toThrow() }) }) ``` - [ ] **Step 2: Run test to verify it fails** ```bash 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** ```typescript // 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" }`. 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
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**
```bash
bun test packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts
```
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
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**
```typescript
// packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
export const ARTIFACT_TOOL_NAME = 'artifact'
export async function describeArtifactTool(): Promise {
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 {
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**
```bash
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**
```typescript
// 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
type ArtifactInput = z.infer
const outputSchema = lazySchema(() =>
z.object({
id: z.string(),
url: z.string(),
expiresAt: z.string(),
}),
)
type OutputSchema = ReturnType
type ArtifactOutput = z.infer
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) {
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**
```bash
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**
```typescript
// 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, 'test report
', '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**
```bash
bun test packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts
```
Expected: PASS (5 tests).
- [ ] **Step 3: Commit**
```bash
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**
```bash
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:
```typescript
export { ArtifactTool } from './tools/ArtifactTool/ArtifactTool.js'
```
- [ ] **Step 3: Verify export works**
```bash
bun -e "import('@claude-code-best/builtin-tools').then(m => console.log(typeof m.ArtifactTool))"
```
Expected output: `object` (the built tool).
- [ ] **Step 4: Commit**
```bash
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:
```typescript
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**
```bash
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:
```bash
bunx tsc --noEmit -p tsconfig.json 2>&1 | head -50
```
Expected: no new errors mentioning ArtifactTool.
- [ ] **Step 4: Commit**
```bash
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**
```typescript
// 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: ".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: ".html", hash: "" } })
\`\`\`
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
Artifact Title
Artifact Title
\`\`\`
The hosting service serves the HTML verbatim (including any \`