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: '
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 \`