Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions plugins/spatial-artifacts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Reference for the plugin. For what it is and how to install it, see the [main RE

The `SpatialArtifact`, `SpatialArtifactComments`, `SpatialArtifactData` and `SpatialArtifactCheck` tools talk to a
daemon on `127.0.0.1`. Nothing leaves your machine except the CDN scripts and Google Fonts a page loads, and what a page
you allowed sends through `sample` (the Anthropic API) or to an HTTP MCP server you configured.
you allowed sends to an HTTP MCP server you configured.

- Gallery: <http://localhost:47328/code/artifacts> (or `/spatial`)
- Artifact: `http://localhost:47328/code/artifact/<uuid>`. Each page runs on its own origin, `<uuid>.localhost`, so
Expand Down Expand Up @@ -50,15 +50,12 @@ Environment variables:
| `SPATIAL_ARTIFACTS_AUTO_OPEN` | on | `0` stops opening artifacts in the browser |
| `SPATIAL_ARTIFACTS_USER` | OS username | your name in comments and the `user` capability |
| `CHROME_PATH` | auto | Chrome binary for `SpatialArtifactCheck` previews |
| `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | unset | credentials for a page's `sample` calls; without them `sample` is off |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | where `sample` sends requests |
| `SPATIAL_ARTIFACTS_SAMPLE_MODELS` | Opus 5.5, Haiku 4.5 for `quick` | JSON `{"quick", "default", "complex"}` model ids for `sample` |

- Sessions read these when they start. The daemon reads the `sample` settings and `SPATIAL_ARTIFACTS_USER` when it
starts, so restart it after changing those.
- Sessions read these when they start. The daemon reads `SPATIAL_ARTIFACTS_USER` when it starts, so restart it
after changing that.
- The daemon keeps running after the session. Stop it with `kill $(jq .pid ~/.spatial-artifacts/server.json)`.
- MCP servers for a page's `mcp` capability come from `~/.claude.json`, the project's `.mcp.json`, then
`~/.spatial-artifacts/mcp.json` (later wins). A page names one as `host:<name>`. Only stdio and streamable HTTP work.
- MCP servers for a page's `mcp` capability come from the project's `.mcp.json`, then `~/.spatial-artifacts/mcp.json`
(later wins). A page names one as `host:<name>`. Only stdio and streamable HTTP work.

## Codex

Expand All @@ -78,7 +75,7 @@ Codex uses the same server and daemon, so both share one set of artifacts.
- Pages run in a sandboxed iframe under a strict CSP: scripts only from five CDN hosts, styles only from Google Fonts,
`connect-src 'self'`, no modals or downloads.
- Comment text, database rows and other page data reach the model fenced and marked as data, not instructions.
- Anything a page does as you (comments, project files, `sample`, MCP servers) is asked in the viewer's own dialog.
- Anything a page does as you (comments, project files, MCP servers) is asked in the viewer's own dialog.
Grants are checked by the daemon on every call. MCP write tools, downloads and sends to Claude are confirmed each
time.

Expand All @@ -105,8 +102,8 @@ Scenes built with `spatial-3d-design` use these viewer hooks; 2D pages don't nee
- A publish won't replace a file this session hasn't read or listed, unless it's named in `overwrite_unread`.
- `SpatialArtifactData` requires `if_version` to overwrite an existing document (page writes are exempt) and caps a
batch at 1 MiB.
- Capabilities: `artifact` (alias `self`), `assets`, `comments`, `db`, `downloads`, `files`, `mcp`, `room`, `sample`
and `user`. `room` only connects tabs on this machine, and `sample` is billed to your API key.
- Capabilities: `artifact` (alias `self`), `assets`, `comments`, `db`, `downloads`, `files`, `mcp`, `room` and
`user`. `room` only connects tabs on this machine.
- `window.claude.hot` carries state across a republish; 3D scenes keep their selection and tool, and show their last
frame until the new version's models have loaded.
- Not available: sharing, audiences, handlers, live-doc sync and live watches.
3 changes: 1 addition & 2 deletions plugins/spatial-artifacts/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,12 @@ export const CAPABILITIES = [
'files',
'mcp',
'room',
'sample',
'self',
'user',
];

// what the viewer allows a page to do as them, asked in the viewer and stored per artifact
export const GRANT_RE = /^(comments|files|sample|mcp:\S{1,200})$/;
export const GRANT_RE = /^(comments|files|mcp:\S{1,200})$/;

export const PAGE_EXTS = ['.html', '.htm', '.md'];

Expand Down
60 changes: 0 additions & 60 deletions plugins/spatial-artifacts/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import * as db from './db.ts';
import * as F from './files.ts';
import { createHosts } from './mcphost.ts';
import { checkJson, createRooms } from './room.ts';
import { createSampler, IMAGE_MAX_BYTES, IMAGE_TYPES, IMAGES_MAX } from './sample.ts';
import * as S from './store.ts';
import { findTitle, injectRuntime, wrapHtml } from './wrap.ts';

Expand Down Expand Up @@ -63,7 +62,6 @@ export const startDaemon = (opts: { port?: number; token?: string } = {}) => {
const hosts = new Map<number, { ids: string[]; at: number; watch?: number }>();
const shown = new Map<string, string>();
const rooms = createRooms();
const sampler = createSampler();
const hosts_ = createHosts();
// one-time approvals for an mcp call the viewer confirmed in the shell: token -> what it allows, until it is used or expires
const approvals = new Map<string, { id: string; what: string; until: number }>();
Expand Down Expand Up @@ -1053,64 +1051,6 @@ export const startDaemon = (opts: { port?: number; token?: string } = {}) => {
if (path.startsWith('/_frame/api/mcp/') && m === 'POST' && need('mcp')) {
return json(res, await frameMcp(id, meta, path.slice('/_frame/api/mcp/'.length), await body(req)));
}
if (path === '/_frame/api/sample/results' && m === 'POST' && need('sample')) {
const b = await body(req);
return json(res, { ok: typeof b.callId === 'string' && sampler.results(b.callId, b.results) });
}
if (path === '/_frame/api/sample' && m === 'POST' && need('sample')) {
const b = await body(req);
res.writeHead(200, {
'content-type': 'application/x-ndjson',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
});
const out = (ev: Record<string, unknown>) => !res.writableEnded && res.write(`${JSON.stringify(ev)}\n`);
if (!S.granted(id, 'sample')) {
return (
out({
type: 'error',
code: 'not_granted',
message: 'the viewer has not allowed this artifact to use Claude',
}),
res.end()
);
}
const imgs = Array.isArray(b.images) ? b.images : [];
if (
imgs.length > IMAGES_MAX ||
imgs.some(
(i: any) =>
!IMAGE_TYPES.includes(i?.type) ||
typeof i?.b64 !== 'string' ||
(i.b64.length * 3) / 4 > IMAGE_MAX_BYTES,
)
) {
return (
out({
type: 'error',
code: 'image_rejected',
message: `at most ${IMAGES_MAX} JPEG, PNG, WebP or GIF images of at most 20 MB`,
}),
res.end()
);
}
const ctl = new AbortController();
res.on('close', () => ctl.abort());
await sampler.run(
id,
{
verb: b.verb === 'json' ? 'json' : 'text',
input: b.input,
tier: b.tier,
images: imgs,
tools: b.tools,
cache: b.cache,
},
out,
ctl.signal,
);
return void res.end();
}
if (path.startsWith('/_frame/api/files/') && m === 'POST' && need('files')) {
return frameFiles(req, res, id, meta, path.slice('/_frame/api/files/'.length));
}
Expand Down
10 changes: 3 additions & 7 deletions plugins/spatial-artifacts/server/mcphost.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// the mcp capability's local half: the viewer's own MCP servers, as Claude Code configures them for the artifact's project
// the mcp capability's local half: the viewer's own MCP servers, from the artifact project's .mcp.json or the plugin's own mcp.json
// (`host:<name>` in a page's manifest). the daemon runs each one itself, over stdio or streamable http, the first time a page
// calls it, and stops it after a quiet spell. claude.ai connectors have no local counterpart
import { type ChildProcessByStdio, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { join } from 'node:path';
import type { Readable, Writable } from 'node:stream';

import { HOME } from './constants.ts';
Expand Down Expand Up @@ -59,7 +58,7 @@ const expand = (v: unknown): any =>
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, expand(x)]))
: v;

/** the servers configured for `project`, by page name; later sources win: user, project .mcp.json, local, then the plugin's own file */
/** the servers configured for `project`, by page name; later sources win: project .mcp.json, then the plugin's own file */
export const configured = (project: string): Map<string, ServerConfig> => {
const out = new Map<string, ServerConfig>();
const add = (o: unknown) => {
Expand All @@ -72,10 +71,7 @@ export const configured = (project: string): Map<string, ServerConfig> => {
}
}
};
const user = readJson(join(homedir(), '.claude.json'));
add(user?.mcpServers);
add(readJson(join(project, '.mcp.json'))?.mcpServers);
add(user?.projects?.[resolve(project)]?.mcpServers);
add(readJson(join(HOME, 'mcp.json'))?.mcpServers);
return out;
};
Expand Down
2 changes: 1 addition & 1 deletion plugins/spatial-artifacts/server/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const ARTIFACT = `This tool turns an HTML file into an Artifact, meaning

Finished work aimed at other people or agents, say a team report or the argument for a decision the team still has to take, is not done in Claude's eyes while its only home is terminal scrollback or a file on disk. Claude turns it into a published Artifact and hands the person the link. A request worded as a question, like "can you write up the plan?", still gets published. If the request names other readers or users of the work (a team, a manager, a reviewer), or where it is headed to be posted or presented (a channel, a meeting), Claude publishes. For a short piece, Claude additionally puts the text in its reply so it can be pasted straight away. If the work could plausibly be forwarded but nobody has said so, Claude adds a one-line offer of the page rather than staying silent. If all the person wants is Claude's own judgement, as in "should we ship this?", and no other reader is mentioned, Claude answers in the terminal and, in place of publishing, offers the page in a single line. An analysis or recommendation prepared for somebody else to act on counts as complete work for its reader, and so it gets published. Apps, sites, dashboards and games always get published, as does anything requested as an artifact or as an HTML or Markdown page for viewing. If what is wanted is the file itself, as in "save these notes as a .md file" or "just give me the .html file", then Claude delivers that file and skips publishing. Advice the person will apply on their own, immediately, inside the code they are editing is not aimed at anyone else, so publishing it is unnecessary.

**Runtime capabilities**: a published page can remember what people do on it and keep state that persists across reloads (\`db\`), know who is viewing (\`user\`), store files people add (\`assets\`), save new versions of itself (\`artifact\`), give the viewer a file to save (\`downloads\`), write comments as the viewer (\`comments\`), read the project's files (\`files\`), call the viewer's own MCP servers (\`mcp\`), reach everyone with it open right now (\`room\`), and ask Claude a question of its own (\`sample\`). The viewer is asked before a page acts as them. Pages ask for these through the \`capabilities\` input. **If any of them would improve the page, Claude must load the \`spatial-capabilities\` skill before it writes the artifact, and in every case before it passes \`capabilities\` or writes \`window.claude.*\` runtime code.** For state, Claude favours a state-keeping capability over browser storage, and reserves \`localStorage\` for conveniences that matter to a single viewer. Certain pages, a document edited in place for example, store new versions of themselves. Claude only learns of such a save as a conflict the next time it publishes; it must then read the page again, fold the changes in and publish once more.
**Runtime capabilities**: a published page can remember what people do on it and keep state that persists across reloads (\`db\`), know who is viewing (\`user\`), store files people add (\`assets\`), save new versions of itself (\`artifact\`), give the viewer a file to save (\`downloads\`), write comments as the viewer (\`comments\`), read the project's files (\`files\`), call the viewer's own MCP servers (\`mcp\`), and reach everyone with it open right now (\`room\`). The viewer is asked before a page acts as them. Pages ask for these through the \`capabilities\` input. **If any of them would improve the page, Claude must load the \`spatial-capabilities\` skill before it writes the artifact, and in every case before it passes \`capabilities\` or writes \`window.claude.*\` runtime code.** For state, Claude favours a state-keeping capability over browser storage, and reserves \`localStorage\` for conveniences that matter to a single viewer. Certain pages, a document edited in place for example, store new versions of themselves. Claude only learns of such a save as a conflict the next time it publishes; it must then read the page again, fold the changes in and publish once more.

**Claude must load the \`spatial-design\` skill before it writes the file**, even for a \`.md\` file written because a skill said so. That skill carries the page contract: the authoring format (HTML, with Markdown only at a loaded skill's request), the title, libraries, storage, the size cap, layout, theming and the icon. It also decides how much design effort a given request merits, and Claude never turns to Markdown to dodge it. Once the skill is loaded, Claude puts the content into a file (with Write/Edit) and passes that path to SpatialArtifact; if a scratchpad directory is listed in the system prompt and the person has not asked for some other location, the file goes there.

Expand Down
Loading
Loading