diff --git a/docs/superpowers/plans/2026-06-20-artifacts-feature.md b/docs/superpowers/plans/2026-06-20-artifacts-feature.md new file mode 100644 index 000000000..8264a99f0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-artifacts-feature.md @@ -0,0 +1,1325 @@ +# 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: '

hello

', + 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: '

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 { + 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": "" }`. 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 \`