diff --git a/README.md b/README.md
index 8ffc353..2cd1493 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,26 @@
Build and share small apps, APIs, and automations on infrastructure you control.
-Create interactive React apps and TypeScript scripts with persistent SQLite
-storage, file uploads, secrets, and scheduled runs. Build with your coding agent
-through MCP, then manage source, versions, and execution in the browser.
+Everything you build is an artifact: a React UI, a TypeScript server, or both,
+with persistent SQLite storage, file uploads, secrets, logs, and scheduled runs.
+Build with your coding agent through MCP, then manage source, versions, and
+execution in the browser.
Deploy on **Cloudflare** or run your own servers with **celld**. Keep tools private,
share them with your team, or publish a public URL.
## What you can build
-- **Apps and dashboards:** React interfaces with their own TypeScript backend,
- SQLite database, and file storage.
-- **APIs and webhooks:** TypeScript HTTP handlers with outbound requests,
- per-script secrets, and persistent SQLite storage.
-- **Automations:** run scripts or app backends on an interval or cron schedule,
- with pause/resume, manual runs, and execution history.
-- **Tools to share:** give apps and scripts readable URLs, choose private or
- public access, and organize them in personal or team libraries.
+- **Dashboards and tools:** artifacts with a React UI and, optionally, their own
+ TypeScript server, SQLite database, and file storage.
+- **APIs and webhooks:** endpoints, which are artifacts with a server and no UI:
+ Workers fetch handlers with outbound requests, secrets, and SQLite storage.
+- **Automations:** run any artifact's server on an interval or cron schedule,
+ with pause/resume, manual runs, logs, and execution history.
+- **Platform resources:** native Workers declare queues, KV, R2, D1, or Durable
+ Objects when the managed runtime is not enough.
+- **Things to share:** give any artifact a readable URL, choose private or
+ public access, and organize it in a personal or team library.
Projects support helper files and pinned bun dependencies. Edit and restore
source revisions, or remix an existing project into a new one with fresh data.
@@ -119,7 +122,7 @@ parameters remain supported as connection defaults.
The connection advertises nine model tools: `artifact_guide`, `artifact_list`,
`artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`,
-`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 53 hosted
+`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 54 hosted
operations remain available through that same connection. Use `artifacts_discover`
to find an operation and retrieve its exact argument schema and relevant guide;
call it through `artifacts_query` for reads or `artifacts_execute` for actions.
@@ -133,22 +136,23 @@ original link. Schedules stay paused until explicitly resumed. MCP exposes
Local file connections expose their supported artifact operations through the
same interface. No configuration switch is needed.
-Ask your agent to read `artifact_guide` before authoring React apps. For scripts,
-inspect `script_write` through `artifacts_discover`, which also returns the script
-guide. For example:
+Ask your agent to read `artifact_guide` before authoring; topic `backend` covers
+servers, endpoints, and schedules. For example:
> Build a team lunch poll with a React UI and a SQLite backend.
> Create a webhook that verifies a signature using a stored secret and saves
> incoming events to SQLite.
-> Make a script that checks an endpoint every hour and records the result.
+> Make an endpoint that checks a URL every hour and records the result.
For sign-in steps, supported credentials, and connection failures, see
[Connect to an existing deployment](docs/authentication.md#connect-to-an-existing-deployment).
The tools support source reads and edits, typechecking, history, restore, remix,
-and execution. MCP Apps clients can also display interactive React artifacts
+and execution. Scripts created with the earlier `script_*` tools keep working, and
+`artifact_*` tools operate on them by name; see
+[legacy scripts](docs/scripts.md#legacy-scripts). MCP Apps clients can also display interactive React artifacts
inside the conversation. Clients without MCP Apps support still receive tool
results and diagnostics.
@@ -163,7 +167,7 @@ artifacts web --port 4784
```
This workspace mode supports local source files, history, and stdio MCP. It does
-not execute scripts, app backends, or schedules, and it does not synchronize with
+not execute artifact servers or schedules, and it does not synchronize with
the full host. Herdr is optional for opening artifacts beside a terminal agent.
See the [local workspace and MCP reference](docs/local-workspace.md) for setup,
inline views, targeted edits, history, and compatibility with older Canvas sources.
@@ -177,8 +181,8 @@ Start with the [documentation index](docs/README.md) to find the guide for your
- [Host commands and local persistence](docs/daemon.md)
- [Deploy to Cloudflare](docs/cloudflare.md)
- [Deploy celld on your own infrastructure](docs/celld-deployment.md)
-- [Scripts, URLs, dependencies, remix, and schedules](docs/scripts.md)
-- [Native Worker apps, manifests, providers, and recovery](docs/native-workers.md)
+- [Endpoints, URLs, dependencies, remix, schedules, and legacy scripts](docs/scripts.md)
+- [Native Workers, manifests, providers, and recovery](docs/native-workers.md)
- [Complete project export and import](docs/project-portability.md)
- [App backends and SQLite](docs/app-backends.md)
- [File storage](docs/files.md)
diff --git a/cloudflare/artifact-execution.ts b/cloudflare/artifact-execution.ts
index 7ce38a3..93db7c0 100644
--- a/cloudflare/artifact-execution.ts
+++ b/cloudflare/artifact-execution.ts
@@ -6,6 +6,7 @@ export type ArtifactScheduleInput = ScheduleTiming & { action?: "get" | "set" |
export type ArtifactRun = { id: string; revision: string; trigger: string; started_at: string; finished_at: string | null; duration_ms: number | null; status: string; http_status: number | null };
export type ArtifactActive = { code: string | null; hash: string; version_id: string; revision: number };
export type ArtifactClaim = { schedule: ArtifactSchedule; active: ArtifactActive | undefined; run: { id: string; started: number } };
+export type ArtifactLog = { timestamp: string; level: string; message: string; run_id?: string | null };
/** Supervisor storage belongs to the host, separate from ArtifactServer's facet. */
export class ArtifactExecution {
@@ -14,6 +15,15 @@ export class ArtifactExecution {
storage.sql.exec("create table if not exists active_server(id integer primary key check(id=1),payload text not null)");
storage.sql.exec("create table if not exists execution_runs(id text primary key,revision text not null,trigger text not null,started_at text not null,finished_at text,duration_ms integer,status text not null,http_status integer)");
storage.sql.exec("update execution_runs set status='interrupted' where status='running'");
+ storage.sql.exec("create table if not exists execution_logs(id integer primary key autoincrement,timestamp text not null,level text not null,message text not null,run_id text)");
+ }
+ record(log: ArtifactLog) {
+ this.storage.sql.exec("insert into execution_logs(timestamp,level,message,run_id) values(?,?,?,?)", log.timestamp, log.level, log.message.slice(0, 2048), log.run_id ?? null);
+ this.storage.sql.exec("delete from execution_logs where id not in (select id from execution_logs order by id desc limit 100)");
+ }
+ logs(limit = 100, runId?: string) {
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100");
+ return this.storage.sql.exec("select timestamp,level,message,run_id from execution_logs where (? is null or run_id=?) order by id desc limit ?", runId ?? null, runId ?? null, limit).toArray();
}
async activate(active: ArtifactActive) {
await this.storage.transaction(async storage => {
diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts
index 5605b2c..c8a92d1 100644
--- a/cloudflare/artifact-routes.ts
+++ b/cloudflare/artifact-routes.ts
@@ -15,6 +15,13 @@ import navigationHost from "../dist/cloudflare/navigation-host.json";
import { artifactApiRequest, artifactApiResponse } from "./artifact-api";
import { SCRIPT_HEADER } from "./script-backend";
import { ARTIFACT_HEADER } from "./backend";
+import type { ArtifactLink } from "./links";
+
+async function linkOwner(link: ArtifactLink, env: Env): Promise {
+ if (link.kind !== "app") return env.LINKS.getByName("deployment").owner(link);
+ try { return (await env.NATIVE_APPS?.getByName("deployment").owner({ id: link.name }))?.owner ?? null; }
+ catch { return null; }
+}
export async function identify(request: Request, env: Env): Promise<{ privateKey: string; user: PluginUser } | Response> {
if (env.AUTH_MODE && !["access", "better-auth"].includes(env.AUTH_MODE)) return Response.json({ error: "Invalid AUTH_MODE" }, { status: 503 });
@@ -44,12 +51,19 @@ export async function artifactRoute(request: Request, env: Env): Promise JSON.parse(atob(result.structuredContent.response.body));
const artifact = "ownership-artifact", script = "ownership-script";
const project = { files: { "helper.ts": "export const retained = 17;" }, dependencies: {} };
- const server = counterServer.replace("extends DurableObject {", "extends DurableObject {")
+ const server = counterServer
.replace('pathname !== "/counter"', 'pathname !== "/counter" && new URL(request.url).pathname !== "/api/counter"')
- .replace("value: row.value,", 'value: row.value, secret: this.env.secrets.TOKEN ?? null, authorization: request.headers.get("authorization"), cookie: request.headers.get("cookie"),');
+ .replace("value: row.value,", 'value: row.value, secret: env.secrets.TOKEN ?? null, authorization: request.headers.get("authorization"), cookie: request.headers.get("cookie"),');
const created = await tool(alice, "artifact_write", { name: artifact, slug: artifact, contents: counterClient, server, project });
const version = (created._meta as any).artifact.versionId;
const scriptSource = 'export default { fetch(request: Request, env: ScriptEnv) { env.sql.exec("create table if not exists counted(n integer)"); if(request.method === "POST") env.sql.exec("insert into counted values(1)"); return Response.json({count:env.sql.exec("select count(*) as n from counted").one().n,secret:env.secrets.TOKEN ?? null}); }}';
diff --git a/cloudflare/backend.ts b/cloudflare/backend.ts
index 88ca7be..e4ec3d5 100644
--- a/cloudflare/backend.ts
+++ b/cloudflare/backend.ts
@@ -4,12 +4,33 @@ import { createHash } from "node:crypto";
import type { ArtifactLibrary } from "./library";
import { scriptRuntimeConfig } from "./script-runtime";
-import { ArtifactExecution, type ArtifactActive, type ArtifactClaim, type ArtifactScheduleInput } from "./artifact-execution";
+import { ArtifactExecution, type ArtifactActive, type ArtifactClaim, type ArtifactLog, type ArtifactScheduleInput } from "./artifact-execution";
+import { SERVER_LOGGING_MODULE } from "./server-logging";
export type BackendEnv = { LOADER: WorkerLoader; LIBRARIES: DurableObjectNamespace; ARTIFACTS_RUNTIME?: string };
export const ARTIFACT_HEADER = "x-artifacts-backend";
const MAX_BODY = 256 * 1024;
type BackendCode = { code: string; hash: string; version_id?: string; trigger?: "http" | "manual" | "schedule" };
+const RUN_HEADER = "x-artifacts-internal-run";
+declare abstract class ServerFacet extends DurableObject { __artifactsTakeLogs(): ArtifactLog[]; }
+// Old immutable compiled revisions export CanvasServer. Preserve the default
+// Worker object too: celld requires it on the main module. The subclass only
+// tags console output with the run and hands it back to this supervisor.
+const SERVER_WRAPPER = `import { takeLogs, execution } from "./logging.js";
+import * as source from "./source.js";
+const Base = source.ArtifactServer ?? source.CanvasServer;
+export class ArtifactServer extends Base {
+ fetch(incoming) {
+ const { id, original } = JSON.parse(incoming.headers.get("${RUN_HEADER}") ?? '{"id":null,"original":null}');
+ const request = new Request(incoming);
+ if (original === null) request.headers.delete("${RUN_HEADER}");
+ else request.headers.set("${RUN_HEADER}", original);
+ return execution.run(id, () => super.fetch(request));
+ }
+ __artifactsTakeLogs() { return takeLogs(); }
+}
+export default source.default;
+`;
function decode(body: string): Uint8Array {
if (body.length > Math.ceil(MAX_BODY / 3) * 4) throw new Error("Artifact request body exceeds 256 KiB");
@@ -53,6 +74,7 @@ async function serializeResponse(response: Response, method: string): Promise {
private activeKey: string | undefined;
+ private facet?: Fetcher;
private execution: ArtifactExecution;
constructor(state: DurableObjectState, env: BackendEnv) {
super(state, env);
@@ -88,6 +110,13 @@ export class ArtifactBackend extends DurableObject {
if (!trashed) await this.ctx.storage.put("trashed", false);
// Restoration deliberately leaves schedules paused until explicitly resumed.
}
+ async logs(input: { limit?: number; run_id?: string } = {}) {
+ if (this.facet) await this.collect(this.facet);
+ return this.execution.logs(input.limit, input.run_id);
+ }
+ private async collect(facet: Fetcher) {
+ try { for (const log of (await facet.__artifactsTakeLogs()).slice(-100)) this.execution.record(log); } catch { /* An aborted isolate cannot supply pending logs. */ }
+ }
async schedule(input: ArtifactScheduleInput = {}) {
if (input.action !== undefined && input.action !== "get" && input.action !== "pause" && await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash");
if (input.request) {
@@ -148,33 +177,39 @@ export class ArtifactBackend extends DurableObject {
const secrets = await this.ctx.storage.get>("secrets") ?? {};
const config = scriptRuntimeConfig(this.env.ARTIFACTS_RUNTIME);
// Identical source owned by different artifacts must not share globals or secrets.
- const key = createHash("sha256").update(JSON.stringify([this.ctx.id.toString(), input.code, secrets, config])).digest("hex");
+ const key = createHash("sha256").update(JSON.stringify([this.ctx.id.toString(), input.code, secrets, config, SERVER_WRAPPER, SERVER_LOGGING_MODULE])).digest("hex");
if (this.activeKey !== key) {
// Reload code while preserving the facet's database. Never delete/reset
// storage as a side effect of an edit, preview, or source restoration.
+ if (this.facet) await this.collect(this.facet);
this.ctx.facets.abort("canvas", "Artifact server code or secrets updated");
this.activeKey = key;
}
// This persisted facet key owns existing user databases.
- const facet = this.ctx.facets.get("canvas", () => {
+ const facet = this.ctx.facets.get("canvas", () => {
const worker = this.env.LOADER.get(key, async () => ({
...config,
mainModule: "artifact-server.js",
- // Old immutable compiled revisions export CanvasServer. Preserve the
- // default Worker object too: celld requires it on the main module.
modules: {
- "artifact-server.js": 'import * as source from "./source.js"; export const ArtifactServer = source.ArtifactServer ?? source.CanvasServer; export default source.default;',
+ "artifact-server.js": SERVER_WRAPPER,
+ "logging.js": SERVER_LOGGING_MODULE.replace("__ARTIFACTS_SCRIPT_SECRETS__", () => JSON.stringify(secrets)),
"source.js": input.code,
},
env: { secrets },
}));
- return { class: worker.getDurableObjectClass("ArtifactServer") };
+ return { class: worker.getDurableObjectClass("ArtifactServer") };
});
- const response = await facet.fetch(request);
+ this.facet = facet;
+ const tagged = new Request(request);
+ tagged.headers.set(RUN_HEADER, JSON.stringify({ id: run.id, original: request.headers.get(RUN_HEADER) }));
+ const response = await facet.fetch(tagged);
this.execution.finish(run, response.status);
+ this.execution.record({ run_id: run.id, timestamp: new Date().toISOString(), level: "request", message: `${request.method} ${response.status} ${Date.now() - run.started}ms` });
+ await this.collect(facet);
return response;
} catch (error) {
this.execution.finish(run, null);
+ this.execution.record({ run_id: run.id, timestamp: new Date().toISOString(), level: "error", message: "Server execution failed" });
throw error;
}
}
diff --git a/cloudflare/compiler.test.ts b/cloudflare/compiler.test.ts
index da17edc..d014341 100644
--- a/cloudflare/compiler.test.ts
+++ b/cloudflare/compiler.test.ts
@@ -401,3 +401,26 @@ test("legacy server class exports compile to canonical ArtifactServer", async ()
expect((await call(source, "compile-server")).ok).toBe(false);
}
}, 60000);
+
+test("module servers compile to the ArtifactServer facet with typed env storage", async () => {
+ for (const handler of [
+ 'export default { async fetch(request, env, ctx) { env.sql.exec("select 1"); env.storage.kv.put("k", 1); return env.storage.transactionSync(() => Response.json({ secret: env.secrets.TOKEN })); } } satisfies ExportedHandler;',
+ // Script source keeps compiling as a server.
+ 'export default { fetch(request: Request, env: ScriptEnv) { env.sql.exec("select 1"); return new Response("ok"); } };',
+ ]) {
+ expect((await call(handler, "typecheck-server")).diagnostics).toEqual([]);
+ const compiled = await call(handler, "compile-server");
+ expect(compiled.diagnostics).toEqual([]);
+ expect(compiled.js).toMatch(/export\s*\{[^}]*\bArtifactServer\b/);
+ }
+ for (const [invalid, message] of [
+ ['export default { nope: 1 };', "default-export an object with a fetch"],
+ ['export default { fetch() { return 1; } };', "must return a Response"],
+ ['export default { fetch(request: Request, env: ServerEnv) { return Response.json(env.sql.missing()); } };', "missing"],
+ ['import x from "left-pad"; export default { fetch() { return new Response(x); } };', "left-pad"],
+ ]) {
+ const compiled = await call(invalid!, "compile-server");
+ expect(compiled.ok).toBe(false);
+ expect(compiled.diagnostics.map(diagnostic => diagnostic.message).join("\n")).toContain(message!);
+ }
+}, 60000);
diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts
index 3b3f134..e8d49c6 100644
--- a/cloudflare/compiler.ts
+++ b/cloudflare/compiler.ts
@@ -15,6 +15,8 @@ const serverPath = "artifact.artifact.server.ts";
const sdkPath = "src/sdk/index.ts";
const compilerFiles: Record = { ...files, ...browserPlugins.files };
compilerFiles["artifact-env.d.ts"] = "interface ArtifactEnv { secrets: Record }";
+// Module servers receive the facet's own storage. ScriptEnv keeps script source compiling as a server.
+compilerFiles["server-env.d.ts"] = 'interface ServerEnv { secrets: Record; sql: SqlStorage; storage: Pick }\ntype ScriptEnv = ServerEnv;\n';
const options: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.ReactJSX,
@@ -27,7 +29,9 @@ const normalize = (path: string) => path.replace(/^\//, "");
let currentSource = "";
let currentProject = emptyProject();
let serverMode = false;
-let scriptMode = false;
+// class: ArtifactServer Durable Object. module: default-exported fetch handler. script: legacy script.
+type ServerKind = "class" | "module" | "script";
+let serverKind: ServerKind = "class";
let sourceVersion = 0;
const serverOptions: ts.CompilerOptions = { ...options, paths: {}, lib: ["lib.es2022.d.ts"], types: ["node"] };
const read = (path: string) => normalize(path) === (serverMode ? serverPath : sourcePath) ? currentSource : currentProject.files[normalize(path)] ?? currentProject.lock[normalize(path)] ?? compilerFiles[normalize(path)];
@@ -35,8 +39,8 @@ const read = (path: string) => normalize(path) === (serverMode ? serverPath : so
// Typechecking is synchronous, so overlapping requests cannot observe another
// source while checking. No per-user project/program cache accumulates here.
const host: ts.LanguageServiceHost = {
- getCompilationSettings: () => scriptMode ? { ...serverOptions, noImplicitAny: false, types: ["node"] } : serverMode ? serverOptions : options,
- getScriptFileNames: () => serverMode ? [`/${serverPath}`, "/node_modules/@cloudflare/workers-types/index.d.ts", ...(!scriptMode ? ["/artifact-env.d.ts"] : [])] : [`/${sourcePath}`],
+ getCompilationSettings: () => serverMode && serverKind !== "class" ? { ...serverOptions, noImplicitAny: false, types: ["node"] } : serverMode ? serverOptions : options,
+ getScriptFileNames: () => serverMode ? [`/${serverPath}`, "/node_modules/@cloudflare/workers-types/index.d.ts", ...(serverKind === "class" ? ["/artifact-env.d.ts"] : serverKind === "module" ? ["/server-env.d.ts"] : [])] : [`/${sourcePath}`],
getScriptVersion: path => {
const file = normalize(path);
// Keep immutable SDK/lib ASTs cached while invalidating every project
@@ -98,16 +102,30 @@ export function typecheckArtifactSource(source: string, project = emptyProject()
diagnostics.push(...sandboxToDiagnostics(path, violations));
}
if (diagnostics.length) return diagnostics;
- return typecheckSource(source, false, false, project);
+ return typecheckSource(source, false, "class", project);
}
export function typecheckArtifactServerSource(source: string, project = emptyProject()): Diagnostic[] {
- return typecheckSource(source, true, false, project);
+ return serverShape(source) === "class" ? typecheckSource(source, true, "class", project) : checkHandler(source, "module", project);
}
-function typecheckSource(source: string, server: boolean, script = false, project = emptyProject()): Diagnostic[] {
+/** Servers exporting ArtifactServer (or legacy CanvasServer) keep the Durable Object class contract. */
+export function serverShape(source: string): "class" | "module" {
+ const file = ts.createSourceFile(serverPath, source, ts.ScriptTarget.ES2022, false, ts.ScriptKind.TS);
+ const names = new Set();
+ for (const statement of file.statements) {
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) ?? [] : [];
+ const exported = modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && !modifiers.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword);
+ if (exported && ts.isClassDeclaration(statement) && statement.name) names.add(statement.name.text);
+ if (exported && ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) for (const element of statement.exportClause.elements) names.add(element.name.text);
+ }
+ return names.has("ArtifactServer") || names.has("CanvasServer") ? "class" : "module";
+}
+
+function typecheckSource(source: string, server: boolean, kind: ServerKind = "class", project = emptyProject()): Diagnostic[] {
currentProject = normalizeProject(project);
- scriptMode = script;
+ serverKind = kind;
serverMode = server;
currentSource = source;
sourceVersion++;
@@ -126,7 +144,7 @@ function typecheckSource(source: string, server: boolean, script = false, projec
...(position ? { line: position.line + 1, column: position.character + 1 } : {}),
};
});
- if (server && !script) diagnostics.push(...validateArtifactServerClass(program));
+ if (server && kind === "class") diagnostics.push(...validateArtifactServerClass(program));
return diagnostics;
}
@@ -186,7 +204,7 @@ export async function compileArtifactServerSource(source: string, project = empt
async function queueCompile(source: string, server: boolean, script = false, project = emptyProject(), native?: { manifest: AppManifest; resources: ResourceLedger }) {
const release = await compileSlot();
if (!release) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] };
- try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); }
+ try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? (serverShape(source) === "class" ? compileServer(source, project) : compileModuleServer(source, project)) : compileSource(source, project)); }
finally { release(); }
}
@@ -316,9 +334,10 @@ async function compileSource(source: string, project: ArtifactProject) {
/** Scripts use Workers globals and can import supported builtins, without the artifact UI restrictions. */
export function compileScriptSource(source: string, project = emptyProject()) { return queueCompile(source, true, true, project); }
-async function compileScript(source: string, project: ArtifactProject) {
- const declarations = '\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }\ntype __ArtifactScriptResult = Response | Promise;\n';
- const diagnostics = typecheckSource(source + declarations, true, true, project);
+/** Typechecks a default-exported Workers fetch handler. */
+function checkHandler(source: string, kind: "module" | "script", project: ArtifactProject): Diagnostic[] {
+ const declarations = (kind === "script" ? "\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }" : "") + "\ntype __ArtifactScriptResult = Response | Promise;\n";
+ const diagnostics = typecheckSource(source + declarations, true, kind, project);
const program = languageService!.getProgram()!;
const file = program.getSourceFiles().find(item => normalize(item.fileName) === serverPath)!;
const checker = program.getTypeChecker();
@@ -327,12 +346,55 @@ async function compileScript(source: string, project: ArtifactProject) {
const handler = exported && checker.getTypeOfSymbolAtLocation(exported, file);
const fetch = handler && checker.getPropertyOfType(handler, "fetch");
const signatures = fetch ? checker.getTypeOfSymbolAtLocation(fetch, file).getCallSignatures() : [];
- if (!signatures.length) diagnostics.push({severity: "error", file: "script.ts", message: "Script must default-export an object with a fetch(request, env, ctx) handler"});
+ const label = kind === "script" ? "Script" : "Server code";
+ if (!signatures.length) diagnostics.push({severity: "error", file: serverPath, message: kind === "script"
+ ? "Script must default-export an object with a fetch(request, env, ctx) handler"
+ : "Server code must default-export an object with a fetch(request, env, ctx) handler, or export class ArtifactServer extending DurableObject from cloudflare:workers"});
const resultType = file.statements.find((node): node is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(node) && node.name.text === "__ArtifactScriptResult");
if (resultType && signatures.some(signature => !checker.isTypeAssignableTo(checker.getReturnTypeOfSignature(signature), checker.getTypeFromTypeNode(resultType.type)))) {
- diagnostics.push({severity: "error", file: "script.ts", message: "Script fetch handler must return a Response or Promise"});
+ diagnostics.push({severity: "error", file: serverPath, message: `${label} fetch handler must return a Response or Promise`});
}
- if (diagnostics.length) return {ok: false, diagnostics: diagnostics.map(item => ({...item, file: item.file === serverPath ? "script.ts" : item.file}))};
+ return kind === "script" ? diagnostics.map(item => ({...item, file: item.file === serverPath ? "script.ts" : item.file})) : diagnostics;
+}
+
+// Wrap the handler in the Durable Object class that backends already execute.
+// Its facet owns the same SQLite database as a class-based server.
+const MODULE_SERVER_ENTRY = `import { DurableObject } from "cloudflare:workers";
+import handler from "./${serverPath}";
+export class ArtifactServer extends DurableObject {
+ async fetch(request) {
+ const ctx = this.ctx;
+ try {
+ const response = await handler.fetch(request, { secrets: this.env.secrets ?? {}, sql: ctx.storage.sql, storage: ctx.storage }, {
+ waitUntil: promise => ctx.waitUntil(Promise.resolve(promise).catch(error => { console.error(error); })),
+ passThroughOnException() {},
+ });
+ if (!(response instanceof Response)) throw new Error("Server fetch handler must return a Response");
+ return response;
+ } catch (error) { console.error(error); return new Response("Server execution failed", { status: 500 }); }
+ }
+}
+export default { fetch() { return new Response("Not found", { status: 404 }); } };
+`;
+
+async function compileModuleServer(source: string, project: ArtifactProject) {
+ const diagnostics = checkHandler(source, "module", project);
+ if (diagnostics.length) return { ok: false, diagnostics };
+ try {
+ const result = await createWorker({ files: { ...project.lock, ...project.files, [serverPath]: source, "server-entry.js": MODULE_SERVER_ENTRY }, entryPoint: "server-entry.js", target: "es2022", sourcemap: false });
+ if (result.warnings?.length) throw new Error(result.warnings.join("\n"));
+ const js = result.modules[result.mainModule];
+ if (typeof js !== "string") throw new Error("Server compiler did not return JavaScript");
+ assertBuiltinImports(js, "Server has an unresolved external import; use Workers runtime builtins or self-contained source");
+ return { ok: true, js, diagnostics: [] as Diagnostic[] };
+ } catch (error) {
+ return { ok: false, diagnostics: [{ severity: "error" as const, message: error instanceof Error ? error.message : String(error), file: serverPath }] };
+ }
+}
+
+async function compileScript(source: string, project: ArtifactProject) {
+ const diagnostics = checkHandler(source, "script", project);
+ if (diagnostics.length) return {ok: false, diagnostics};
try {
const result = await createWorker({ files: { ...project.lock, ...project.files, "script.ts": source }, entryPoint: "script.ts", target: "es2022", sourcemap: false });
if (result.warnings?.length) throw new Error(result.warnings.join("\n"));
diff --git a/cloudflare/library.ts b/cloudflare/library.ts
index 530d7a4..6c20584 100644
--- a/cloudflare/library.ts
+++ b/cloudflare/library.ts
@@ -15,7 +15,7 @@ type DraftRow = {
workspace: string; name: string; source: string; server_source: string | null; source_hash: string;
project: string; state: string; created_at: string; updated_at: string;
};
-type DraftListRow = Pick;
+type DraftListRow = Pick & { server_only: number };
type StoredVersion = Version & { server_source: string | null; compiled_id: string | null; project: ArtifactProject };
export type CompiledArtifact = { id: string; runtime: string; client_js: string; server_js: string | null };
type CompiledRow = { id: string; runtime: string; source_hash: string; server_hash: string | null; project_hash: string };
@@ -27,6 +27,11 @@ function projectFile(project: ArtifactProject, file: string): string {
function projectHash(project?: ArtifactProject): string { return sha256(JSON.stringify(normalizeProject(project))); }
+// Server-only artifacts read and edit their server by default.
+function defaultPart(row: Pick): "client" | "server" {
+ return row.source === "" && row.server_source !== null ? "server" : "client";
+}
+
function bytes(value: string): number { return encoder.encode(value).byteLength; }
function workspaceName(value: unknown): string {
@@ -67,6 +72,11 @@ function sourceText(value: unknown, appendNewline = false): string {
return source;
}
+// An empty browser source marks a server-only artifact; it has no UI to compile or render.
+function assertHasPart(source: string, server: string | null) {
+ if (source === "" && server === null) throw new Error("An artifact needs browser source, server source, or both");
+}
+
function runtimeName(value: unknown): string {
if (typeof value !== "string" || !value || bytes(value) > 4096) throw new Error("runtime is required and must not exceed 4 KiB");
return value;
@@ -160,7 +170,7 @@ export class ArtifactLibrary extends DurableObject {
const workspace = workspaceName(input.workspace), name = artifactName(input.name);
const source = sourceText(input.source), server = input.server_source === null ? null : sourceText(input.server_source);
const runtime = runtimeName(input.runtime);
- if (typeof input.client_js !== "string" || !input.client_js ||
+ if (typeof input.client_js !== "string" || (!input.client_js && source !== "") ||
(server === null ? input.server_js !== null : typeof input.server_js !== "string" || !input.server_js)) throw new Error("invalid compiled artifact output");
const project = normalizeProject(input.project);
const project_hash = projectHash(project);
@@ -213,23 +223,27 @@ export class ArtifactLibrary extends DurableObject {
const workspace = optionalWorkspace(input.workspace);
const { offset, limit } = pagination(input, 100, 100);
const result = workspace
- ? rows(this.sql.exec("select workspace,name,source_hash,updated_at from drafts where workspace = ? order by workspace,name limit ? offset ?", workspace, limit, offset))
- : rows(this.sql.exec("select workspace,name,source_hash,updated_at from drafts order by workspace,name limit ? offset ?", limit, offset));
+ ? rows(this.sql.exec("select workspace,name,source_hash,updated_at,source = '' as server_only from drafts where workspace = ? order by workspace,name limit ? offset ?", workspace, limit, offset))
+ : rows(this.sql.exec("select workspace,name,source_hash,updated_at,source = '' as server_only from drafts order by workspace,name limit ? offset ?", limit, offset));
return result.map(row => ({
id: row.name, name: `${row.name}${ARTIFACTS_SUFFIX}`, path: sourcePath(row.workspace, row.name),
- workspace: row.workspace, source_hash: row.source_hash, updated_at: row.updated_at,
+ workspace: row.workspace, source_hash: row.source_hash, updated_at: row.updated_at, ...(row.server_only ? { server_only: true } : {}),
}));
}
- writeDraft(input: { workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject; expected_revision?: string | null }) {
+ /** Omitted source or server_source preserves the saved part; null removes it. */
+ writeDraft(input: { workspace: string; name: string; source?: string | null; server_source?: string | null; project?: ArtifactProject; expected_revision?: string | null }) {
const workspace = workspaceName(input.workspace); const name = artifactName(input.name);
- const source = sourceText(input.source, true); const source_hash = sha256(source); const timestamp = now();
+ const requestedSource = input.source === undefined ? undefined : input.source === null || input.source === "" ? "" : sourceText(input.source, true);
+ const timestamp = now();
const requestedServer = input.server_source === undefined ? undefined
: input.server_source === null ? null : sourceText(input.server_source);
return this.state.storage.transactionSync(() => {
const current = first(this.sql.exec("select * from drafts where workspace = ? and name = ?", workspace, name));
assertProjectRevision(input.expected_revision, current ? projectRevision(current.source, current.server_source, this.projectStorage.read(current.project)) : null);
+ const source = requestedSource ?? current?.source ?? "", source_hash = sha256(source);
const server_source = requestedServer === undefined ? current?.server_source ?? null : requestedServer;
+ assertHasPart(source, server_source);
const project = input.project === undefined ? current?.project ?? this.projectStorage.encode(emptyProject()) : this.projectStorage.encode(input.project);
this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,project,state,created_at,updated_at)
values (?, ?, ?, ?, ?, ?, '{}', ?, ?)
@@ -245,6 +259,7 @@ export class ArtifactLibrary extends DurableObject {
importDraft(input: { workspace: string; name: string; source: string; server_source: string | null; project: ArtifactProject; runtime: string }) {
const workspace = workspaceName(input.workspace), name = artifactName(input.name), runtime = runtimeName(input.runtime);
const source = sourceText(input.source), server_source = input.server_source === null ? null : sourceText(input.server_source);
+ assertHasPart(source, server_source);
const project = normalizeProject(input.project);
return this.state.storage.transactionSync(() => {
if (first(this.sql.exec("select name from drafts where workspace=? and name=? union all select name from artifacts where workspace=? and name=?", workspace, name, workspace, name))) throw new Error("Destination artifact already exists; choose a new name");
@@ -285,7 +300,7 @@ export class ArtifactLibrary extends DurableObject {
readRange(input: { workspace: string; name: string; part?: "client" | "server"; file?: string; start_line?: number; end_line?: number }) {
const workspace = workspaceName(input.workspace); const name = artifactName(input.name);
const row = this.draft(workspace, name);
- const part = input.part ?? "client";
+ const part = input.part ?? defaultPart(row);
if (part !== "client" && part !== "server") throw new Error("part must be client or server");
if (input.file === undefined && part === "server" && row.server_source === null) throw new Error("artifact has no server source");
const project = this.projectStorage.read(row.project);
@@ -310,15 +325,16 @@ export class ArtifactLibrary extends DurableObject {
editDraft(input: { workspace: string; name: string; part?: "client" | "server"; file?: string; edits: ArtifactEdit[]; expected_hash?: string }) {
const workspace = workspaceName(input.workspace); const name = artifactName(input.name);
- const part = input.part ?? "client";
- if (part !== "client" && part !== "server") throw new Error("part must be client or server");
+ if (input.part !== undefined && input.part !== "client" && input.part !== "server") throw new Error("part must be client or server");
if (!Array.isArray(input.edits) || input.edits.length === 0) throw new Error("edits must be a non-empty array");
if (input.expected_hash !== undefined && (typeof input.expected_hash !== "string" || !/^[a-f0-9]{64}$/.test(input.expected_hash))) {
throw new Error("expected_hash must be a SHA-256 hex string");
}
return this.state.storage.transactionSync(() => {
const original = this.draft(workspace, name);
+ const part = input.part ?? defaultPart(original);
if (input.file === undefined && part === "server" && original.server_source === null) throw new Error("artifact has no server source");
+ if (input.file === undefined && part === "client" && original.source === "") throw new Error("artifact has no browser source");
let project = this.projectStorage.read(original.project);
const originalSelected = input.file === undefined ? (part === "client" ? original.source : original.server_source!) : projectFile(project, input.file);
const originalHash = input.file === undefined && part === "client" ? original.source_hash : sha256(originalSelected);
diff --git a/cloudflare/links.ts b/cloudflare/links.ts
index e18d804..f87dba8 100644
--- a/cloudflare/links.ts
+++ b/cloudflare/links.ts
@@ -7,7 +7,11 @@ import type { ArtifactFiles } from "./files";
type TrashEnvironment = { BACKENDS?: DurableObjectNamespace; SCRIPT_BACKENDS?: DurableObjectNamespace; FILE_BACKENDS?: DurableObjectNamespace };
-export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string };
+export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script" | "app"; name: string };
+/** Native app links name the app's permanent ID; the controller resolves its current owner. */
+export function nativeLinkTarget(workspace: string, id: string): ArtifactTarget {
+ return { libraryKey: "native", workspace, kind: "app", name: id };
+}
export type LiveRevision = { id: string; revision: number; revision_token: string };
export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string; live?: LiveRevision };
export type ArtifactLink = ArtifactTarget & LinkUpdate & { slug: string; access: "private" | "public"; generation: number };
diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts
index 70ebd34..b35549e 100644
--- a/cloudflare/managed-service.ts
+++ b/cloudflare/managed-service.ts
@@ -1,6 +1,6 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { Env } from "./worker";
-import type { ArtifactTarget } from "./links";
+import { nativeLinkTarget, type ArtifactTarget } from "./links";
import type { LibrarySelection } from "./ownership";
import type { PluginInvocationContext } from "./plugins";
import type { PluginRequest } from "../src/plugins/types";
@@ -11,7 +11,7 @@ import { CloudArtifactService } from "./service";
import { workspaceTool } from "./workspace-tools";
import { galleryAction, gallerySource } from "./gallery-tools";
import { ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "../src/mcp/settings-contract";
-import { parseProjectArchive } from "../src/project-archive";
+import { parseProjectArchive, projectArchive } from "../src/project-archive";
import { ownershipName } from "./ownership";
import { callDiscoveryTool, isDiscoveryTool } from "../src/mcp/discovery";
import { validateToolArguments } from "../src/mcp/validation";
@@ -20,6 +20,13 @@ import * as toolValidators from "../dist/cloudflare/tool-validators.js";
const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string; library_scope?: "private" | "team" };
+// Artifact operations a legacy script can still serve; null operations need a UI.
+const LEGACY_SCRIPT_TOOLS: Record = {
+ artifact_read: "script_read", artifact_edit: "script_edit", artifact_write: "script_write", artifact_version: "script_version",
+ artifact_restore: "script_restore", artifact_export: "script_export", artifact_secrets: "script_secrets", artifact_schedule: "script_schedule",
+ artifact_runs: "script_runs", artifact_logs: "script_logs", artifact_request: "script_run",
+ artifact_open: null, artifacts_preview: null, artifact_typecheck: null, artifact_compile: null, artifact_files: null,
+};
/** Management requests select an owner; execution continues on immutable storage. */
export class ManagedArtifactService {
@@ -88,7 +95,7 @@ export class ManagedArtifactService {
// A failed compile may still have saved a draft. Resolve current ownership
// after the operation, including writes admitted before a library move.
if (changesGallery) await this.links.changed({ ...target,
- ...(name.endsWith("_remix") ? { name: ownershipName(target.kind, args.new_name) } : {}),
+ ...(name.endsWith("_remix") ? { name: ownershipName(target.kind === "script" ? "script" : "artifact", args.new_name) } : {}),
});
}
}
@@ -123,11 +130,17 @@ export class ManagedArtifactService {
return text({ items, next_offset: items.length === 100 ? (args.offset as number | undefined ?? 0) + 100 : null });
}
if (/^(artifact|script)_(delete|undelete)$/.test(name)) {
- const kind = name.startsWith("script_") ? "script" : "artifact";
- const result = await this.links.setTrashed({ ...this.selection(kind, {
- name: args.name as string, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}),
- }), name: args.name as string }, name.endsWith("_delete"));
- return text({ ok: true, ...result });
+ const workspace = typeof args.workspace === "string" ? { workspace: args.workspace } : {};
+ const trash = async (kind: "artifact" | "script") => text({ ok: true, ...await this.links.setTrashed({ ...this.selection(kind, {
+ name: args.name as string, ...workspace,
+ }), name: args.name as string }, name.endsWith("_delete")) });
+ if (name.startsWith("script_")) return trash("script");
+ try { return await trash("artifact"); }
+ catch (error) {
+ // artifact_delete/undelete also reach a legacy script with that name.
+ if (!(error instanceof Error) || !/not found/.test(error.message)) throw error;
+ try { return await trash("script"); } catch { throw error; }
+ }
}
if (name === "artifacts_tool") return galleryAction(this, args);
if (name === "artifacts_source") return gallerySource(this, args);
@@ -139,14 +152,54 @@ export class ManagedArtifactService {
return artifact ? { ...opened, structuredContent: { ...opened.structuredContent, appPreview: artifact } } : opened;
}
if (name.startsWith("app_")) return this.nativeTool(name, args);
+ if (name === "artifact_link" && args.kind === "app") return this.linkNativeApp(args);
if (["artifact_guide", "script_guide", "plugins_list", "plugin_guide", "artifact_plugin_call"].includes(name)) return this.physical({ libraryKey: this.libraryKey, workspace: this.workspace }).callTool(name, args);
const kind = name.startsWith("script_") ? "script" : name === "artifact_link" ? args.kind as "artifact" | "script" : "artifact";
- if (["artifact_list", "script_list", "artifact_history", "script_history"].includes(name)) {
+ if (name === "artifact_list") {
+ // Legacy scripts are server-only artifacts to callers; page both stores together.
+ const offset = args.offset as number | undefined ?? 0;
+ const [artifacts, scripts] = await Promise.all((["artifact", "script"] as const).map(async kind => {
+ const rows = await this.links.catalog({ libraryKey: this.libraryKey, workspace: this.workspace, kind, method: "listDrafts", offset });
+ for (const row of rows) Object.assign(row, await this.linkDetails(kind, row.workspace, kind === "artifact" ? row.id! : row.name));
+ return rows;
+ }));
+ return text({ artifacts, legacy_scripts: scripts, next_offset: artifacts!.length === 100 || scripts!.length === 100 ? offset + 100 : null });
+ }
+ if (["script_list", "artifact_history", "script_history"].includes(name)) {
const history = name.endsWith("_history"), offset = args.offset as number | undefined ?? 0;
const rows = await this.links.catalog({ libraryKey: this.libraryKey, workspace: this.workspace, kind, method: history ? "history" : "listDrafts", name: args.name as string | undefined, offset });
+ if (name === "artifact_history" && args.name !== undefined && rows.length === 0 && offset === 0 && await this.legacyScriptExists({ name: args.name as string })) return this.callTool("script_history", args);
if (!history) for (const row of rows) Object.assign(row, await this.linkDetails(kind, row.workspace, kind === "artifact" ? row.id! : row.name));
return text({ [history ? "versions" : kind === "script" ? "scripts" : "artifacts"]: rows, next_offset: rows.length === 100 ? offset + 100 : null });
}
+ const workspace = typeof args.workspace === "string" ? { workspace: args.workspace } : {};
+ if (name === "artifact_write" && await this.legacyScriptExists({ name: args.name as string, ...workspace }) && !await this.itemExists("artifact", { name: args.name as string, ...workspace })) {
+ if (typeof args.contents === "string") throw new Error(`${String(args.name)} is a legacy script, which cannot have a UI. Use artifact_remix to copy it into a new artifact first.`);
+ return this.legacyScriptTool(name, args);
+ }
+ if ((name === "artifact_import" || name === "artifact_remix") && await this.legacyScriptExists({ name: args.new_name as string, ...workspace })) {
+ throw new Error(`${String(args.new_name)} is already used by a legacy script; choose a new name`);
+ }
+ if (name === "artifact_remix" && !await this.itemExists("artifact", { name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...workspace })
+ && await this.legacyScriptExists({ name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...workspace })) {
+ return this.remixLegacyScript(args);
+ }
+ if (name === "artifact_import" && (args.archive as { kind?: unknown } | undefined)?.kind === "script") {
+ const archive = parseProjectArchive(args.archive, "script");
+ return this.callTool(name, { ...args, archive: { ...archive, kind: "artifact", source: "", server_source: archive.source } });
+ }
+ if (name in LEGACY_SCRIPT_TOOLS && name !== "artifact_write") {
+ try { return await this.callArtifactTool(kind, name, args); }
+ catch (error) {
+ if (!(error instanceof Error) || !/(artifact|version|item) not found/.test(error.message)) throw error;
+ if (!await this.legacyScriptExists({ name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...workspace })) throw error;
+ return this.legacyScriptTool(name, args);
+ }
+ }
+ return this.callArtifactTool(kind, name, args);
+ }
+
+ private async callArtifactTool(kind: "artifact" | "script", name: string, args: Record) {
const selection = this.selection(kind, { name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}) });
if (name.endsWith("_import")) {
parseProjectArchive(args.archive, kind);
@@ -159,12 +212,61 @@ export class ManagedArtifactService {
return this.callSelectedTool(target, name, args);
}
+ private async itemExists(kind: "artifact" | "script", selection: Selection): Promise {
+ if (selection.name === undefined && selection.version_id === undefined) return false;
+ try {
+ const target = await this.links.admit(this.selection(kind, selection));
+ if (selection.version_id !== undefined) return true;
+ const library = kind === "artifact" ? this.env.LIBRARIES.getByName(target.libraryKey) : this.env.SCRIPTS.getByName(target.libraryKey);
+ await library.readRange({ workspace: target.workspace, name: target.name, end_line: 1 });
+ return true;
+ } catch (error) {
+ if (error instanceof Error && /not found|invalid name|name is required/.test(error.message)) return false;
+ throw error;
+ }
+ }
+ private legacyScriptExists(selection: Selection) { return this.itemExists("script", selection); }
+
+ /** Serves an artifact_* call from a script saved before scripts became server-only artifacts. */
+ private async legacyScriptTool(name: string, args: Record): Promise {
+ const tool = LEGACY_SCRIPT_TOOLS[name];
+ const label = String(args.name ?? args.version_id);
+ if (!tool) throw new Error(`${label} is a legacy script without a UI, so ${name} does not apply. Use artifact_request, or artifact_remix it into an artifact.`);
+ const { part, preview: _preview, contents: _contents, server, events_offset: _events, ...scriptArgs } = args;
+ if (part === "client") throw new Error(`${label} is a legacy script and has no browser source`);
+ const workspace = typeof args.workspace === "string" ? { workspace: args.workspace } : {};
+ if (name === "artifact_write") {
+ if (typeof server !== "string") throw new Error(`${label} is a legacy script: pass its source as server, or artifact_remix it into an artifact to add a UI`);
+ scriptArgs.contents = server;
+ }
+ if (name === "artifact_restore") scriptArgs.name = (await this.links.admit(this.selection("script", { version_id: args.version_id as string, ...workspace }))).name;
+ if (name === "artifact_request") {
+ if (typeof args.name !== "string") throw new Error("Select a legacy script by name");
+ // Legacy handlers see the full deployment path; artifact paths are relative to the item URL.
+ const link = await this.links.find(await this.links.admit(this.selection("script", { name: args.name, ...workspace })));
+ const request = args.request as ArtifactHttpRequest;
+ const suffix = request.path === "/" || request.path.startsWith("/?") ? request.path.slice(1) : request.path;
+ if (link) scriptArgs.request = { ...request, path: `/${link.slug}${suffix}` };
+ }
+ return this.callTool(tool, scriptArgs);
+ }
+
+ /** Remixing a legacy script creates a server-only artifact with fresh data. */
+ private async remixLegacyScript(args: Record): Promise {
+ const workspace = typeof args.workspace === "string" ? { workspace: args.workspace } : {};
+ const source = await this.scriptReadSource({ name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...workspace });
+ const archive = projectArchive("artifact", { name: source.name, source: "", server_source: source.source, project: source.project });
+ const result = await this.callTool("artifact_import", { ...workspace, new_name: args.new_name, archive, ...(args.slug === undefined ? {} : { slug: args.slug }), ...(args.preview === undefined ? {} : { preview: args.preview }) });
+ result.content.push({ type: "text", text: `Created server-only artifact ${String(args.new_name)} from legacy script ${source.name}. Its handler now receives paths relative to the artifact URL, and its storage, secrets and schedule start empty.` });
+ return result;
+ }
+
private nativeController() {
if (!this.env.NATIVE_APPS) throw new Error("Native apps are not enabled on this deployment");
return this.env.NATIVE_APPS.getByName("deployment");
}
private async nativeTool(tool: string, args: Record): Promise {
- if (tool === "app_guide") return text({ guide: "Native apps preserve the default Worker and named Durable Object exports. app_write accepts source, a strict app-owned manifest and optional project files/dependencies. Manifest fields: main (relative JS/TS path), compatibility_date, compatibility_flags, vars, secrets (names only), bindings (name -> {type:kv|r2|d1|queue|durable-object, resource:stable-name, class_name:DO export}), triggers:{crons:[],queues:[resource-name]}. Resource names permanently identify storage; binding/export renames preserve it and removed resources remain reserved. Set secrets with app_secrets. app_read/history/restore/reconcile manage persistent revisions and interrupted deployments. Restores retain current data and current secrets. Providers cannot change on an existing app. celld-local and celld-fleet accept trusted code only; celld-fleet uses the existing bucket-backed fleet and its coordinated publisher. URLs are private /apps//?workspace=&library=, require library authentication, stream HTTP and strip management credentials/cookies. Cloudflare uses native queue/cron delivery. celld-fleet uses native queues and host-coordinated cron dispatch into each app's own environment. celld-local operator does not support WebSockets. No destructive resource removal or schema/data rollback is provided." });
+ if (tool === "app_guide") return text({ guide: "Native apps preserve the default Worker and named Durable Object exports. app_write accepts source, a strict app-owned manifest and optional project files/dependencies. Manifest fields: main (relative JS/TS path), compatibility_date, compatibility_flags, vars, secrets (names only), bindings (name -> {type:kv|r2|d1|queue|durable-object, resource:stable-name, class_name:DO export}), triggers:{crons:[],queues:[resource-name]}. Resource names permanently identify storage; binding/export renames preserve it and removed resources remain reserved. Set secrets with app_secrets. app_read/history/restore/reconcile manage persistent revisions and interrupted deployments. Restores retain current data and current secrets. Providers cannot change on an existing app. celld-local and celld-fleet accept trusted code only; celld-fleet uses the existing bucket-backed fleet and its coordinated publisher. Management URLs are private /apps//?workspace=&library= and require library authentication. artifact_link({kind:'app',name,slug,access}) adds a root / URL, private or public, that follows the app across library moves and passes slug-relative paths. Both stream HTTP and strip management credentials/cookies. Cloudflare uses native queue/cron delivery. celld-fleet uses native queues and host-coordinated cron dispatch into each app's own environment. celld-local operator does not support WebSockets. No destructive resource removal or schema/data rollback is provided." });
const controller = this.nativeController(), input = { owner: this.libraryKey, workspace: this.workspace, name: args.name as string };
let result: unknown;
switch (tool) {
@@ -182,6 +284,8 @@ export class ManagedArtifactService {
default: throw new Error("Unknown app tool");
}
const payload = result as Record;
+ if (tool === "app_read" || (tool === "app_write" && typeof payload.id === "string")) Object.assign(payload, await this.nativeLinkDetails(this.workspace, payload.id as string));
+ if (tool === "app_list") for (const app of payload.apps as { id: string; workspace: string }[]) Object.assign(app, await this.nativeLinkDetails(app.workspace, app.id));
if (["app_write", "app_restore", "app_reconcile", "app_move"].includes(tool) || (tool === "app_secrets" && args.secrets)) {
await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace });
if (tool === "app_move") await this.links.nativeAppChanged({ libraryKey: args.library === "team" ? "team" : this.privateKey, workspace: this.workspace });
@@ -189,6 +293,16 @@ export class ManagedArtifactService {
const failed = payload.ok === false || (payload.deployment as { ok?: boolean } | undefined)?.ok === false;
return { ...text(result), structuredContent: payload, isError: failed };
}
+ private async linkNativeApp(args: Record): Promise {
+ const { id } = await this.nativeController().identity({ owner: this.libraryKey, workspace: this.workspace, name: args.name as string });
+ await this.links.set({ ...nativeLinkTarget(this.workspace, id), slug: args.slug as string, access: args.access as "private" | "public" | undefined });
+ await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace });
+ return text({ ok: true, ...await this.nativeLinkDetails(this.workspace, id) });
+ }
+ private async nativeLinkDetails(workspace: string, id: string) {
+ const link = await this.links.find(nativeLinkTarget(workspace, id));
+ return link ? { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}` } : {};
+ }
nativeFetch(name: string, request: Request) {
const headers = new Headers(request.headers);
headers.set("x-artifacts-app-selection", JSON.stringify({ owner: this.libraryKey, workspace: this.workspace, name }));
@@ -233,6 +347,7 @@ export class ManagedArtifactService {
const name = method === "listDrafts" && kind === "artifact" ? row.id! : row.name;
const key = JSON.stringify(kind === "script" ? ["script", row.workspace, name] : [row.workspace, name]);
const item: GalleryArtifact = artifacts.get(key) ?? { key, name, workspace: row.workspace, library_scope: this.libraryKey === "team" ? "team" : "private", working: false, kind, versions: [] };
+ if (row.server_only) item.serverOnly = true;
if (method === "listDrafts") item.working = true;
else item.versions.push({ id: row.version_id ?? row.id!, revision: row.revision as number, createdAt: row.created_at!, reason: row.reason as string, serveCount: kind === "artifact" ? row.serve_count as number : 0 });
artifacts.set(key, item);
@@ -253,7 +368,7 @@ export class ManagedArtifactService {
: null;
hasMore ||= workers?.next_offset != null;
return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, trash: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace,
- ...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}),
+ ...(workers ? { workerApps: await Promise.all(workers.apps.map(async app => ({ ...app, ...await this.nativeLinkDetails(app.workspace, app.id), kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) }))), nativeAppProviders: workers.providers } : {}),
artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null };
}
}
diff --git a/cloudflare/mcp-discovery.test.ts b/cloudflare/mcp-discovery.test.ts
index 383c715..0300a62 100644
--- a/cloudflare/mcp-discovery.test.ts
+++ b/cloudflare/mcp-discovery.test.ts
@@ -128,7 +128,7 @@ afterAll(async () => {
if (directory) await rm(directory, { recursive: true, force: true });
});
-integrationTest("one HTTP MCP connection discovers all 53 operations and performs complete artifact, script and native app workflows", async () => {
+integrationTest("one HTTP MCP connection discovers all 54 operations and performs complete artifact, script and native app workflows", async () => {
const listed = (await client.listTools()).tools;
const model = listed.filter(tool => !((tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility?.every(scope => scope === "app")));
expect(model.map(tool => tool.name).sort()).toEqual([
@@ -136,7 +136,7 @@ integrationTest("one HTTP MCP connection discovers all 53 operations and perform
"artifacts_discover", "artifacts_execute", "artifacts_query",
]);
const catalog = payload(await client.callTool({ name: "artifacts_discover", arguments: { limit: 50 } }));
- expect(catalog.total).toBe(53);
+ expect(catalog.total).toBe(54);
const rest = payload(await client.callTool({ name: "artifacts_discover", arguments: { offset: 50, limit: 50 } }));
expect(rest.next_offset).toBeNull();
for (const operation of [...catalog.operations, ...rest.operations]) await inspect(operation.operation);
@@ -171,6 +171,8 @@ integrationTest("one HTTP MCP connection discovers all 53 operations and perform
expect(JSON.stringify(schedule)).toContain("86400");
const runs = payload(await invoke("artifact_runs", { name: "compact-artifact" }));
expect(runs.runs.length).toBeGreaterThan(0);
+ const logs = payload(await invoke("artifact_logs", { name: "compact-artifact", run_id: runs.runs[0].id }));
+ expect(logs.logs.length).toBeGreaterThan(0);
const upload = payload(await invoke("artifact_files", { name: "compact-artifact", request: { operation: "upload", name: "fixture.txt", size: 5, type: "text/plain" } })).result;
expect((await fetch(upload.url, { method: "PUT", body: "hello" })).ok).toBe(true);
const files = payload(await invoke("artifact_files", { name: "compact-artifact", request: { operation: "list" } }, "artifacts_query")).result;
diff --git a/cloudflare/native-gallery-test-worker.ts b/cloudflare/native-gallery-test-worker.ts
index cd99389..3effc62 100644
--- a/cloudflare/native-gallery-test-worker.ts
+++ b/cloudflare/native-gallery-test-worker.ts
@@ -3,6 +3,7 @@ export { ScriptLibrary } from "./scripts";
export { ArtifactLinks } from "./links";
export { NativeApps } from "./native-worker/controller";
import { ManagedArtifactService } from "./managed-service";
+import { artifactRoute } from "./artifact-routes";
import type { Env } from "./worker";
export default {
@@ -12,6 +13,7 @@ export default {
const workspace = url.searchParams.get("workspace") ?? "default";
const service = new ManagedArtifactService(env, workspace, owner, "alice", url.origin, url.origin, { user: { subject: owner, authority: "test" }, env });
try {
+ if (url.pathname.startsWith("/route/")) return await artifactRoute(new Request(new URL(url.pathname.slice("/route".length), url.origin), request), env) ?? new Response("No route", { status: 404 });
if (url.pathname === "/subscribe") return service.subscribeGallery(url.searchParams.get("all") === "1");
if (url.pathname === "/gallery") return Response.json(await service.gallery(url.searchParams.get("all") === "1", Number(url.searchParams.get("offset") ?? 0)));
if (url.pathname === "/seed-artifact") return Response.json(await env.LIBRARIES.getByName(owner).writeDraft({ workspace, name: "report", source: "source" }));
diff --git a/cloudflare/native-gallery.test.ts b/cloudflare/native-gallery.test.ts
index 6ecfebb..e6a4f6e 100644
--- a/cloudflare/native-gallery.test.ts
+++ b/cloudflare/native-gallery.test.ts
@@ -63,6 +63,14 @@ test("gallery lists Worker drafts across workspaces with owner isolation, pagina
expect(JSON.stringify(all)).not.toContain("fixture-provider-secret");
expect((await gallery()).workerApps?.map(item => item.workspace)).toEqual(["default"]);
expect((await gallery("owner=bob&all=1")).workerApps?.map(item => item.name)).toEqual(["bob-only"]);
+ // Native links share the deployment slug namespace and name the app's permanent ID.
+ const link = (owner: string, slug: string) => request(`/tools?owner=${owner}`, { name: "artifact_link", arguments: { kind: "app", name: "example", slug, access: "public" } });
+ expect(JSON.parse((await link("alice", "example-app")).content[0].text)).toMatchObject({ ok: true, slug: "example-app", access: "public", url: `${origin}/example-app` });
+ await expect(link("bob", "taken-by-bob")).rejects.toThrow("App not found");
+ expect((await gallery()).workerApps?.find(item => item.name === "example")).toMatchObject({ slug: "example-app", access: "public", url: `${origin}/example-app` });
+ expect(JSON.parse((await request("/tools", { name: "app_read", arguments: { name: "example" } })).content[0].text)).toMatchObject({ slug: "example-app" });
+ const draftRoute = await fetch(`${origin}/route/example-app/jobs`);
+ expect([draftRoute.status, await draftRoute.text()]).toEqual([503, "App deployment requires reconciliation"]);
for (let index = 0; index < 100; index++) await write(`worker-${String(index).padStart(3, "0")}`, "workspace=other");
const first = await gallery("all=1"), second = await gallery("all=1&offset=100");
expect(first.workerApps).toHaveLength(100);
@@ -79,6 +87,8 @@ test("gallery lists Worker drafts across workspaces with owner isolation, pagina
expect(await otherWorkspace!.changes()).toEqual([]);
expect((await gallery()).workerApps).toEqual([]);
expect((await gallery("owner=team&all=1")).workerApps?.map(item => item.name)).toEqual(["example"]);
+ expect((await gallery("owner=team&all=1")).workerApps?.[0]).toMatchObject({ slug: "example-app" });
+ expect((await fetch(`${origin}/route/example-app`)).status).toBe(503);
} finally {
subscriptions.forEach(subscription => subscription.close());
await runtime.dispose();
diff --git a/cloudflare/native-worker/controller.ts b/cloudflare/native-worker/controller.ts
index fa436d2..1de99dd 100644
--- a/cloudflare/native-worker/controller.ts
+++ b/cloudflare/native-worker/controller.ts
@@ -71,7 +71,7 @@ export class NativeApps extends DurableObject {
return result;
}
private summary(row: Row) {
- return { name: row.name, workspace: row.workspace, provider: row.provider, desired_revision: row.desired, active_revision: row.active,
+ return { id: row.id, name: row.name, workspace: row.workspace, provider: row.provider, desired_revision: row.desired, active_revision: row.active,
revision_token: createHash("sha256").update(row.draft).digest("hex"), status: row.status, stage: row.stage, error: row.error, updated_at: row.updated_at };
}
list(input: { owner: string; workspace?: string; offset?: number }) {
@@ -87,6 +87,13 @@ export class NativeApps extends DurableObject {
const revision = input.revision_id ? this.revision(row.id, input.revision_id) : null;
return { ...this.summary(row), ...(revision ? { source: revision.source, manifest: revision.manifest, project: revision.project ?? emptyProject(), revision_id: revision.id } : JSON.parse(row.draft) as Draft) };
}
+ /** The permanent ID survives library moves; deployment-wide links refer to it. */
+ identity(input: Selection) { return { id: this.row(input).id }; }
+ owner(input: { id: string }) {
+ if (typeof input.id !== "string") throw new Error("App not found");
+ const row = this.byId(input.id);
+ return { owner: row.owner, workspace: row.workspace, name: row.name };
+ }
history(input: Selection & { offset?: number }) {
const row = this.row(input), offset = input.offset ?? 0;
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid app pagination");
@@ -278,7 +285,10 @@ export class NativeApps extends DurableObject {
async fetch(request: Request): Promise {
const encoded = request.headers.get("x-artifacts-app-selection");
if (!encoded) return new Response("Not found", { status: 404 });
- const row = this.row(JSON.parse(encoded) as Selection);
+ const selected = JSON.parse(encoded) as Selection | { id: string };
+ let row: Row;
+ try { row = "id" in selected ? this.byId(selected.id) : this.row(selected); }
+ catch { return new Response("Not found", { status: 404 }); }
if (row.status !== "active" || !row.endpoint) return new Response("App deployment requires reconciliation", { status: 503 });
const headers = new Headers(request.headers);
for (const key of ["x-artifacts-app-selection", "x-artifacts-gateway-token", "x-artifacts-target-url", "authorization", "cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) headers.delete(key);
diff --git a/cloudflare/ownership.ts b/cloudflare/ownership.ts
index 70b68c2..b592f7c 100644
--- a/cloudflare/ownership.ts
+++ b/cloudflare/ownership.ts
@@ -9,7 +9,7 @@ export type CatalogRow = {
workspace: string; name: string; id?: string; version_id?: string; created_at?: string;
artifact_id?: string; source_path?: string; path?: string; source_hash?: string; updated_at?: string;
runtime?: string; reason?: string; restored_from?: string | null; revision?: number; serve_count?: number;
- active_hash?: string | null; kind?: "script";
+ active_hash?: string | null; kind?: "script"; server_only?: boolean;
};
type OwnershipRow = { physical: string; owner: string; workspace: string; kind: string; name: string };
diff --git a/cloudflare/script-backend.ts b/cloudflare/script-backend.ts
index a20dc76..07fb235 100644
--- a/cloudflare/script-backend.ts
+++ b/cloudflare/script-backend.ts
@@ -7,6 +7,7 @@ import type { ArtifactHttpRequest } from "../src/httpTypes";
import { sha256 } from "./library";
import { scriptResponse } from "./script-service-http";
import { scriptRuntimeConfig } from "./script-runtime";
+import { SERVER_LOGGING_MODULE } from "./server-logging";
export type ScriptLog = { timestamp: string; level: string; message: string; run_id?: string };
export type ScriptRequest = {code: string; hash: string; secrets: Record; request: Request; trigger?: "http" | "manual" | "schedule"; run_id?: string};
@@ -20,23 +21,6 @@ export const SCRIPT_HEADER = "x-artifact-internal-script";
// Each script has its own isolate and native SQLite facet. Only the explicit
// script secrets are supplied; the application's environment is never copied.
-const logging = `import { AsyncLocalStorage } from "node:async_hooks";
-const secrets = __ARTIFACTS_SCRIPT_SECRETS__;
-export const execution = new AsyncLocalStorage();
-let logs = [];
-const clean = (value, secrets) => {
- let message;
- try { message = typeof value === "string" ? value : value instanceof Error ? value.message : JSON.stringify(value); } catch { message = "[unserializable]"; }
- message = String(message);
- for (const secret of Object.values(secrets)) if (secret) message = message.split(secret).join("[REDACTED]");
- return message.slice(0, 2048);
-};
-for (const level of ["log", "info", "warn", "error", "debug"]) console[level] = (...args) => {
- logs.push({run_id: execution.getStore(), timestamp: new Date().toISOString(), level, message: args.slice(0, 16).map(value => clean(value, secrets)).join(" ").slice(0, 2048)});
- if (logs.length > 100) logs.shift();
-};
-export function takeLogs() { const result = logs; logs = []; return result; }
-`;
const adapter = `import { DurableObject } from "cloudflare:workers";
import { takeLogs, execution } from "./logging.js";
import handler from "./user.js";
@@ -78,13 +62,13 @@ export class ScriptBackend extends DurableObject {
state.storage.sql.exec("update execution_runs set status='interrupted' where status='running'");
}
private cacheKey(input: Omit) {
- return sha256(JSON.stringify([this.ctx.id.toString(), input.code, input.secrets, this.runtimeConfig, adapter, logging]));
+ return sha256(JSON.stringify([this.ctx.id.toString(), input.code, input.secrets, this.runtimeConfig, adapter, SERVER_LOGGING_MODULE]));
}
private worker(input: Omit) {
const hash=this.cacheKey(input);
return this.env.LOADER.get(hash,async()=>({
...this.runtimeConfig,
- mainModule:"adapter.js",modules:{"adapter.js":adapter,"logging.js":logging.replace("__ARTIFACTS_SCRIPT_SECRETS__", () => JSON.stringify(input.secrets)),"user.js":input.code},
+ mainModule:"adapter.js",modules:{"adapter.js":adapter,"logging.js":SERVER_LOGGING_MODULE.replace("__ARTIFACTS_SCRIPT_SECRETS__", () => JSON.stringify(input.secrets)),"user.js":input.code},
env:{secrets:input.secrets},
}));
}
diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts
index 7d944ab..95a45e7 100644
--- a/cloudflare/script-guide.ts
+++ b/cloudflare/script-guide.ts
@@ -1,10 +1,12 @@
-export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs
+export const HOSTED_SCRIPT_GUIDE = `Hosted legacy scripts and root URLs
+
+Create new endpoints, webhooks and scheduled jobs as server-only artifacts: artifact_write({name,slug,access,server}) without contents, using the same export default {fetch} handler with env.sql, env.storage and env.secrets (see artifact_guide topic backend). Their URLs receive paths relative to the slug. script_* tools remain supported for scripts created with them, and artifact_* tools also operate on those scripts by name; artifact_remix copies one into a server-only artifact.
One MCP connection supports both personal and team libraries. Pass library_scope:"private" or library_scope:"team" on any tool call; omit it to use the connection URL's default. The selector always uses authenticated ownership and cannot select another user's personal library. In artifacts_query/artifacts_execute, put the selector inside the discovered operation's arguments.
artifact_delete and script_delete move owned items to recoverable Trash. URLs stop working, file-transfer grants are revoked and schedules pause. Names and slugs stay reserved; history, data, files and secrets are preserved. artifacts_trash lists this library's Trash (all:true includes every workspace), and artifact_undelete/script_undelete restore an item. Restored schedules stay paused until explicitly resumed. Requests admitted before deletion may finish.
-Artifacts and scripts coexist. Use artifact_write with an optional slug, or artifact_link({kind:"artifact",name,slug,access}), to give an artifact a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated.
+Use artifact_write with an optional slug, or artifact_link({kind:"artifact",name,slug,access}), to give an artifact a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated.
Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Use project.files for relative helper modules and project.dependencies for exact npm versions. The entrypoint accepts at most 256 KiB UTF-8; helpers support 64 files and 1 MiB total. The service resolves dependencies when their declarations change and archives integrity-verified source and types. Source compilation and execution use the archived lock without downloading dependencies.
diff --git a/cloudflare/server-logging.ts b/cloudflare/server-logging.ts
new file mode 100644
index 0000000..4e76fca
--- /dev/null
+++ b/cloudflare/server-logging.ts
@@ -0,0 +1,19 @@
+// Loaded beside user server code. Captures bounded console output per run and
+// redacts the secrets it was configured with; never imported by the host.
+export const SERVER_LOGGING_MODULE = `import { AsyncLocalStorage } from "node:async_hooks";
+const secrets = __ARTIFACTS_SCRIPT_SECRETS__;
+export const execution = new AsyncLocalStorage();
+let logs = [];
+const clean = (value, secrets) => {
+ let message;
+ try { message = typeof value === "string" ? value : value instanceof Error ? value.message : JSON.stringify(value); } catch { message = "[unserializable]"; }
+ message = String(message);
+ for (const secret of Object.values(secrets)) if (secret) message = message.split(secret).join("[REDACTED]");
+ return message.slice(0, 2048);
+};
+for (const level of ["log", "info", "warn", "error", "debug"]) console[level] = (...args) => {
+ logs.push({run_id: execution.getStore(), timestamp: new Date().toISOString(), level, message: args.slice(0, 16).map(value => clean(value, secrets)).join(" ").slice(0, 2048)});
+ if (logs.length > 100) logs.shift();
+};
+export function takeLogs() { const result = logs; logs = []; return result; }
+`;
diff --git a/cloudflare/service.ts b/cloudflare/service.ts
index 3af26a1..70c2746 100644
--- a/cloudflare/service.ts
+++ b/cloudflare/service.ts
@@ -135,7 +135,7 @@ export class CloudArtifactService {
const generation = await this.hosted?.links.begin(target);
const previous = args.project === undefined ? undefined : await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("artifact not found")) return undefined; throw error; });
const project = args.project === undefined ? undefined : await resolveProject(args.project, previous?.project, fetch, browserRuntime.sharedVersions);
- return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project, expected_revision: args.expected_revision as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }, args.preview !== false);
+ return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string | null | undefined, server_source: args.server as string | null | undefined, project, expected_revision: args.expected_revision as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }, args.preview !== false);
}
case "artifact_edit": {
const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", args.name as string));
@@ -148,7 +148,7 @@ export class CloudArtifactService {
}
case "artifact_typecheck": {
const snapshot = await this.snapshot({ name: args.name as string });
- const diagnostics = [...typecheckArtifactSource(snapshot.source, snapshot.project), ...(snapshot.server_source === null ? [] : typecheckArtifactServerSource(snapshot.server_source, snapshot.project))];
+ const diagnostics = [...(snapshot.source === "" ? [] : typecheckArtifactSource(snapshot.source, snapshot.project)), ...(snapshot.server_source === null ? [] : typecheckArtifactServerSource(snapshot.server_source, snapshot.project))];
return text({ path: snapshot.path, check: formatArtifactCheck(diagnostics), diagnostics }, diagnostics.length > 0);
}
case "artifact_compile": {
@@ -166,6 +166,12 @@ export class CloudArtifactService {
const { _meta, ...payload } = preview;
return { ...text(payload, !preview.ok), structuredContent: { ...payload, ...(name === "artifacts_preview" && _meta?.artifact ? { appPreview: _meta.artifact } : {}) }, ...(_meta ? { _meta } : {}) };
}
+ case "artifact_logs": {
+ const snapshot = await this.snapshot({ name: args.name as string });
+ const backend = this.backends.getByName(JSON.stringify([this.libraryKey, this.workspace, snapshot.name]));
+ const payload = { logs: await backend.logs({ limit: args.limit as number | undefined, run_id: args.run_id as string | undefined }) };
+ return { ...text(payload), structuredContent: payload };
+ }
case "artifact_schedule":
case "artifact_runs": {
const snapshot = await this.snapshot({ name: args.name as string });
@@ -207,8 +213,10 @@ export class CloudArtifactService {
async preview(snapshot: Snapshot, compiled?: Compiled) {
compiled ??= await this.savedCompilation(snapshot);
- const base = { ...await this.artifacts.linkDetails(this.artifacts.target("artifact", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics };
- if (!compiled.ok || !compiled.js || !compiled.artifact) return { ...base, ok: false, _meta: undefined };
+ const ui = snapshot.source !== "";
+ const base = { ...await this.artifacts.linkDetails(this.artifacts.target("artifact", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics, ...(ui ? {} : { ui: false }) };
+ const js = compiled.js;
+ if (!compiled.ok || js === undefined || (ui && !js) || !compiled.artifact) return { ...base, ok: false, _meta: undefined };
const { version, event } = await this.library.recordServe({
workspace: snapshot.workspace, name: snapshot.name, source: snapshot.source,
server_source: snapshot.server_source, project: snapshot.project,
@@ -216,7 +224,9 @@ export class CloudArtifactService {
...(snapshot.version_id ? { version_id: snapshot.version_id } : {}),
});
const artifact = { workspace: snapshot.workspace, name: version.name, versionId: version.id, revision: version.revision, eventId: event!.id, sourceHash: version.source_hash };
- return { ...base, ok: true, artifact, _meta: { artifact: { ...artifact, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}), ...(this.fileStorage ? { files: true } : {}) } satisfies ArtifactAppPayload } };
+ // A server-only artifact has nothing to render; its URL and artifact_request invoke the server.
+ if (!ui) return { ...base, ok: true, artifact, _meta: undefined };
+ return { ...base, ok: true, artifact, _meta: { artifact: { ...artifact, js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}), ...(this.fileStorage ? { files: true } : {}) } satisfies ArtifactAppPayload } };
}
private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}, deliverPreview = true): Promise {
@@ -271,8 +281,8 @@ export class CloudArtifactService {
...(snapshot.compiled_id ? { id: snapshot.compiled_id } : { source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project, runtime }),
});
if (saved) return { ok: true, js: saved.client_js, diagnostics: [], artifact: saved };
- const client = await compileArtifactSource(snapshot.source, snapshot.project);
- if (!client.ok || !client.js) return client;
+ const client = snapshot.source === "" ? { ok: true, js: "", diagnostics: [] } : await compileArtifactSource(snapshot.source, snapshot.project);
+ if (!client.ok || client.js === undefined) return client;
const server = snapshot.server_source === null ? null : await compileArtifactServerSource(snapshot.server_source, snapshot.project);
if (server && (!server.ok || !server.js)) return { ok: false, diagnostics: server.diagnostics };
const artifact = await this.library.saveCompiled({ workspace: snapshot.workspace, name: snapshot.name,
@@ -380,6 +390,7 @@ export class CloudArtifactService {
const key = JSON.stringify([draft.workspace, draft.id]);
const artifact = artifacts.get(key) ?? { key, name: draft.id, workspace: draft.workspace, working: false, versions: [] };
artifact.working = true;
+ if (draft.server_only) artifact.serverOnly = true;
artifacts.set(key, artifact);
}
let scriptsHaveMore = false;
diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts
index fcc1e25..e1ba02c 100644
--- a/cloudflare/tool-contract.ts
+++ b/cloudflare/tool-contract.ts
@@ -27,13 +27,17 @@ for (const tool of CLOUD_MCP_TOOLS) {
: " Results are paginated, 100 per response; pass next_offset as offset for the next page.";
}
if (tool.name === "artifact_write") {
+ tool.description = "Create or replace an artifact: a browser UI, a server, or both. A server-only artifact is an HTTP endpoint, webhook or scheduled job; its URL runs the server. Before creating one, call artifact_guide if you have not read it in this conversation. Import UI helpers from sidequery/artifacts. Pass a kebab-case name without slashes. Returns TypeScript check diagnostics; only valid saves replace the running revision.";
tool.inputSchema.properties = { ...tool.inputSchema.properties,
- server: { type: ["string", "null"], description: "Optional native server TypeScript exporting class ArtifactServer extends DurableObject from cloudflare:workers. Use this.ctx.storage.sql for native SQLite and this.ctx.storage.kv for key/value storage. Omit to preserve; null removes server code without deleting its database." },
+ contents: { type: ["string", "null"], description: "Browser .artifact.tsx source. Omit to preserve the saved UI (a new artifact without contents has no UI); null removes the UI." },
+ server: { type: ["string", "null"], description: "Server TypeScript. Prefer export default { async fetch(request, env, ctx) { ... } } satisfies ExportedHandler, where env.sql is the artifact's SQLite database, env.storage provides kv and transactionSync, and env.secrets holds its secrets. A class ArtifactServer extends DurableObject from cloudflare:workers is also accepted. Omit to preserve; null removes server code without deleting its database." },
};
+ tool.inputSchema.required = ["name"];
+ tool.inputSchema.anyOf = [{ required: ["contents"] }, { required: ["server"] }];
}
if (tool.name === "artifact_read" || tool.name === "artifact_edit") {
tool.inputSchema.properties = { ...tool.inputSchema.properties,
- part: { type: "string", enum: ["client", "server"], default: "client", description: "Select browser TSX or native server TypeScript." },
+ part: { type: "string", enum: ["client", "server"], description: "Select browser TSX or server TypeScript. Defaults to client, or server for an artifact without a UI." },
};
}
if (tool.name === "artifact_compile") {
@@ -54,7 +58,7 @@ export const ARTIFACTS_REQUEST_SCHEMA = {
};
CLOUD_MCP_TOOLS.push({
name: "artifact_request",
- description: "Send an HTTP request to an artifact's native server. Its fetch handler can use native Durable Object SQLite/KV APIs. Select name or version_id. Database contents remain live across source edits and restores. Returns a response envelope with base64 body; artifactFetch provides ordinary Request/Response behavior in the UI.",
+ description: "Send an HTTP request to an artifact's server. request.path is relative to the artifact's URL. Its handler can use the artifact's SQLite/KV storage. Select name or version_id. Database contents remain live across source edits and restores. Returns a response envelope with base64 body; artifactFetch provides ordinary Request/Response behavior in the UI.",
inputSchema: { type: "object", properties: {
name: { type: "string" }, version_id: { type: "string" }, request: ARTIFACTS_REQUEST_SCHEMA,
}, required: ["request"], anyOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false },
@@ -95,12 +99,12 @@ function addScriptTool(name: string, description: string, properties: Record. Use project.files for relative modules and project.dependencies for exact package versions. Dependencies are resolved and integrity-verified at write time, then archived as a source lock; execution and replay do not install packages. This loads the module for validation but does not execute the handler.", {
+addScriptTool("script_write", "Legacy: prefer artifact_write with server and no contents for new endpoints. Call script_guide first. Create or replace arbitrary Workers-compatible TypeScript exporting default { fetch(request, env, ctx) }. Saved drafts and history are retained even when validation fails; only valid updates replace the running code. Scripts have outbound fetch, env.secrets, and env.sql for persistent SQLite. Slug defaults to the name; URLs use /. Use project.files for relative modules and project.dependencies for exact package versions. Dependencies are resolved and integrity-verified at write time, then archived as a source lock; execution and replay do not install packages. This loads the module for validation but does not execute the handler.", {
...nameProperty, contents: { type: "string", maxLength: 262144 }, ...slugProperties,
}, ["name", "contents"]);
addScriptTool("script_read", "Read script source with bounded line ranges, optionally from a historical version. Does not execute the script.", {
@@ -118,7 +122,7 @@ addScriptTool("script_schedule", "Inspect or configure recurring execution of th
addScriptTool("script_runs", "Inspect recent invocation outcomes, revision hashes, triggers, and HTTP-handler durations. Retains 1000 runs, returns the latest 100. Interrupted means completion is unknown; background waitUntil work and streamed response completion are not part of HTTP-handler success.", {...nameProperty,limit:{type:"integer",minimum:1,maximum:100}},["name"]);
addScriptTool("script_logs", "Read bounded recent execution logs and errors without executing the script. Optionally filter by run_id from script_runs.", { ...nameProperty, run_id:{type:"string"}, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]);
addScriptTool("script_secrets", "Set or delete per-script secrets (null deletes). Omit secrets to list names. Values are never returned or added to source history.", { ...nameProperty, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]);
-addScriptTool("artifact_secrets", "Set or delete per-artifact backend secrets (null deletes). Omit secrets to list names. Values are available as this.env.secrets in ArtifactServer, never returned or added to source history. Edits and restores preserve secrets; remixes and imports start empty.", { ...nameProperty, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]);
+addScriptTool("artifact_secrets", "Set or delete per-artifact server secrets (null deletes). Omit secrets to list names. Values are available as env.secrets (this.env.secrets in an ArtifactServer class), never returned or added to source history. Edits and restores preserve secrets; remixes and imports start empty.", { ...nameProperty, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]);
addScriptTool("plugins_list", "List deployment-installed plugins, browser availability, and operation schemas and read-only hints. Does not execute operations.", {});
CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true };
@@ -129,11 +133,13 @@ addScriptTool("artifact_plugin_call", "Call a deployment-installed server functi
}, ["plugin", "operation", "input"]);
CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
-addScriptTool("artifact_schedule", "Configure recurring requests to an artifact's latest validated native server. Uses Durable Object alarms, with no automatic retry after uncertain side effects. Exactly one of interval_seconds or cron is required for set; timezone defaults to UTC. run_now executes the configured request, including while paused. Historical previews do not change the scheduled revision.", {
+addScriptTool("artifact_schedule", "Configure recurring requests to an artifact's latest validated server. Uses Durable Object alarms, with no automatic retry after uncertain side effects. Exactly one of interval_seconds or cron is required for set; timezone defaults to UTC. run_now executes the configured request, including while paused. Historical previews do not change the scheduled revision.", {
name: { type: "string", minLength: 1 }, action: { type: "string", enum: ["get", "set", "pause", "resume", "run_now"], default: "get" },
interval_seconds: { type: "integer", minimum: 60, maximum: 31536000 }, cron: { type: "string", maxLength: 256 }, timezone: { type: "string", maxLength: 100 }, request: ARTIFACTS_REQUEST_SCHEMA,
}, ["name"]);
-addScriptTool("artifact_runs", "Read recent native artifacts server invocations, their source revision, trigger, duration and outcome. Host-owned history is separate from application SQLite. An interrupted run may already have performed writes and is never automatically retried.", { name: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]);
+addScriptTool("artifact_logs", "Read bounded recent server console output, request summaries and errors without executing the artifact. Optionally filter by run_id from artifact_runs. Secret values are redacted from captured output.", { name: { type: "string", minLength: 1 }, run_id: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]);
+CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true };
+addScriptTool("artifact_runs", "Read recent artifact server invocations, their source revision, trigger, duration and outcome. Host-owned history is separate from application SQLite. An interrupted run may already have performed writes and is never automatically retried.", { name: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]);
CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true };
// Project source is additive to legacy entrypoints; dependency locks are generated by the service.
const projectProperty = { type: "object", properties: {
diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts
index afd1ba0..c7ea2f5 100644
--- a/cloudflare/worker.test.ts
+++ b/cloudflare/worker.test.ts
@@ -320,7 +320,7 @@ test("native artifact SQLite works through MCP and the gallery, persists across
expect(await callCounter()).toMatchObject({ value: 2 });
expect((await callCounter()).generation).toBeUndefined();
const serverRead = payload(await client.callTool({ name: "artifact_read", arguments: { name: "counter", part: "server", start_line: 1, end_line: 1 } }));
- expect(serverRead.source).toBe('import { DurableObject } from "cloudflare:workers";\n');
+ expect(serverRead.source).toBe("export default {\n");
expect(serverRead.server_source).toBeUndefined();
const mismatch = await client.callTool({ name: "artifact_request", arguments: { name: "other-counter", version_id: savedVersion.versionId, request: { path: "/counter" } } });
expect(mismatch.isError).toBe(true);
@@ -1129,3 +1129,90 @@ test("MCP gallery editor keeps full projects, workspace routing, CAS errors and
} finally { await team.close(); }
await expect(client.callTool({ name: "artifacts_tool", arguments: { workspace, tool: "artifact_write", arguments: { name } } })).rejects.toThrow("Invalid tool arguments");
}, 60_000);
+
+test("server-only artifacts serve module handlers at their whole URL with logs, secrets and slug-relative paths", async () => {
+ const server = `export default { async fetch(request, env, ctx) {
+ env.sql.exec("create table if not exists hits(value integer)");
+ env.storage.transactionSync(() => env.sql.exec("insert into hits values (1)"));
+ env.storage.kv.put("last", new URL(request.url).pathname);
+ console.log("handled", env.secrets.TOKEN ?? "unset");
+ return Response.json({ path: new URL(request.url).pathname, method: request.method, count: env.sql.exec("select count(*) as n from hits").one().n, last: env.storage.kv.get("last"), cookie: request.headers.get("cookie") });
+ } } satisfies ExportedHandler;`;
+ const created = await client.callTool({ name: "artifact_write", arguments: { name: "endpoint", slug: "endpoint", access: "public", server } });
+ expect(created.isError).not.toBe(true);
+ expect(payload(created)).toMatchObject({ ok: true, url: `${origin}/endpoint` });
+ expect((created.structuredContent as any).preview).toMatchObject({ ok: true, ui: false });
+ const gallery = await (await fetch(`${origin}/api/gallery?workspace=test`)).json() as { artifacts: { name: string; serverOnly?: boolean; versions: { revision: number }[] }[] };
+ expect(gallery.artifacts.find(item => item.name === "endpoint")).toMatchObject({ serverOnly: true, working: true });
+ for (const item of gallery.artifacts) expect(item.versions.every(version => typeof version.revision === "number")).toBe(true);
+ await client.callTool({ name: "artifact_secrets", arguments: { name: "endpoint", secrets: { TOKEN: "endpoint-sensitive-value" } } });
+ const root = await fetch(`${origin}/endpoint`, { headers: { Cookie: "session=management" } });
+ expect(await root.json()).toEqual({ path: "/", method: "GET", count: 1, last: "/", cookie: null });
+ const posted = await fetch(`${origin}/endpoint/hooks/github?x=1`, { method: "POST", body: "{}", headers: { Origin: "https://external.example" } });
+ expect(posted.status).toBe(200);
+ expect(await posted.json()).toMatchObject({ path: "/hooks/github", method: "POST", count: 2 });
+ const requested = payload(await client.callTool({ name: "artifact_request", arguments: { name: "endpoint", request: { path: "/manual" } } }));
+ expect(requested.status).toBe(200);
+ const logs = JSON.stringify(await client.callTool({ name: "artifact_logs", arguments: { name: "endpoint" } }));
+ expect(logs).toContain("handled [REDACTED]");
+ expect(logs).not.toContain("endpoint-sensitive-value");
+ const read = payload(await client.callTool({ name: "artifact_read", arguments: { name: "endpoint" } }));
+ expect(read).toMatchObject({ part: "server", source: server });
+ expect(await (await fetch(`${origin}/api/source?workspace=test&name=endpoint`)).text()).toBe(server);
+ const editedServer = server.replace("method: request.method", "method: request.method, edited: true");
+ const edited = await client.callTool({ name: "artifact_edit", arguments: { name: "endpoint", edits: [{ old_text: "method: request.method", new_text: "method: request.method, edited: true" }] } });
+ expect(edited.isError).not.toBe(true);
+ expect(await (await fetch(`${origin}/endpoint/again`)).json()).toMatchObject({ path: "/again", count: 4, edited: true });
+ const invalid = await client.callTool({ name: "artifact_write", arguments: { name: "endpoint", server: "export default { nope: 1 };" } });
+ expect(invalid.isError).toBe(true);
+ expect(JSON.stringify(invalid)).toContain("default-export an object with a fetch");
+ expect((await fetch(`${origin}/endpoint`)).status).toBe(200);
+ expect((await client.callTool({ name: "artifact_write", arguments: { name: "empty-artifact", contents: null, server: null } })).isError).toBe(true);
+
+ // A UI can be added later; the server then answers only under /api.
+ const withUi = await client.callTool({ name: "artifact_write", arguments: { name: "endpoint", contents: source, server: editedServer } });
+ expect(withUi.isError).not.toBe(true);
+ expect((await fetch(`${origin}/endpoint`)).headers.get("content-type")).toContain("text/html");
+ expect(await (await fetch(`${origin}/endpoint/api/data`)).json()).toMatchObject({ path: "/api/data", count: 6, edited: true });
+}, 60_000);
+
+test("legacy scripts stay on their own storage and answer artifact operations", async () => {
+ const code = `export default { async fetch(request: Request, env: ScriptEnv) {
+ env.sql.exec("create table if not exists hits(value integer)");
+ env.sql.exec("insert into hits values (1)");
+ return Response.json({ path: new URL(request.url).pathname, count: env.sql.exec("select count(*) as n from hits").one().n });
+ } };`;
+ expect((await client.callTool({ name: "script_write", arguments: { name: "legacy-handler", slug: "legacy-handler", access: "public", contents: code } })).isError).not.toBe(true);
+ const listed = payload(await client.callTool({ name: "artifact_list", arguments: {} }));
+ expect(listed.legacy_scripts.some((item: { name: string }) => item.name === "legacy-handler")).toBe(true);
+ expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "legacy-handler" } })).source).toBe(code);
+ const requested = await client.callTool({ name: "artifact_request", arguments: { name: "legacy-handler", request: { path: "/inner" } } });
+ expect(JSON.parse(atob((requested.structuredContent as any).response.body))).toEqual({ path: "/legacy-handler/inner", count: 1 });
+ expect(payload(await client.callTool({ name: "artifact_secrets", arguments: { name: "legacy-handler", secrets: { KEY: "value" } } })).names).toEqual(["KEY"]);
+ expect(payload(await client.callTool({ name: "artifact_history", arguments: { name: "legacy-handler" } })).versions).toHaveLength(1);
+ const updated = await client.callTool({ name: "artifact_write", arguments: { name: "legacy-handler", server: code.replace("count:", "updated: true, count:") } });
+ expect(updated.isError).not.toBe(true);
+ expect(await (await fetch(`${origin}/legacy-handler`)).json()).toEqual({ path: "/legacy-handler", updated: true, count: 2 });
+ expect((await client.callTool({ name: "artifact_write", arguments: { name: "legacy-handler", contents: source } })).isError).toBe(true);
+ const opened = await client.callTool({ name: "artifact_open", arguments: { name: "legacy-handler" } });
+ expect(opened.isError).toBe(true);
+ expect(JSON.stringify(opened)).toContain("legacy script");
+ // script_* calls keep their original contract.
+ expect(payload(await client.callTool({ name: "script_read", arguments: { name: "legacy-handler" } })).source).toContain("updated: true");
+
+ const remixed = await client.callTool({ name: "artifact_remix", arguments: { name: "legacy-handler", new_name: "upgraded-handler" } });
+ expect(remixed.isError).not.toBe(true);
+ expect(JSON.stringify(remixed.content)).toContain("relative to the artifact URL");
+ expect(payload(await client.callTool({ name: "artifact_link", arguments: { kind: "artifact", name: "upgraded-handler", slug: "upgraded-handler", access: "public" } })).url).toBe(`${origin}/upgraded-handler`);
+ expect(await (await fetch(`${origin}/upgraded-handler/inner`)).json()).toEqual({ path: "/inner", updated: true, count: 1 });
+ expect(await (await fetch(`${origin}/legacy-handler`)).json()).toMatchObject({ count: 3 });
+
+ const archive = (await client.callTool({ name: "script_export", arguments: { name: "legacy-handler" } })).structuredContent;
+ const imported = await client.callTool({ name: "artifact_import", arguments: { new_name: "imported-handler", archive } });
+ expect(imported.isError).not.toBe(true);
+ expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "imported-handler" } })).part).toBe("server");
+ expect((await client.callTool({ name: "artifact_delete", arguments: { name: "legacy-handler" } })).isError).not.toBe(true);
+ expect((await fetch(`${origin}/legacy-handler`)).status).toBe(404);
+ expect((await client.callTool({ name: "artifact_undelete", arguments: { name: "legacy-handler" } })).isError).not.toBe(true);
+ expect(await (await fetch(`${origin}/legacy-handler`)).json()).toMatchObject({ updated: true });
+}, 60_000);
diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts
index 74c3775..231437c 100644
--- a/cloudflare/worker.ts
+++ b/cloudflare/worker.ts
@@ -220,8 +220,11 @@ app.get("/api/source", async c => {
}
if (c.req.query("format") === "json") return c.json(snapshot);
c.header("Content-Type", "text/plain; charset=utf-8");
- if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="artifact.artifact.tsx"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".artifact.tsx")}`);
- return c.body(snapshot.source);
+ // A server-only artifact's entrypoint is its server source.
+ const serverOnly = snapshot.source === "" && snapshot.server_source !== null;
+ const suffix = serverOnly ? ".artifact.server.ts" : ".artifact.tsx";
+ if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="artifact${suffix}"; filename*=UTF-8''${encodeURIComponent(snapshot.name + suffix)}`);
+ return c.body(serverOnly ? snapshot.server_source! : snapshot.source);
});
app.get("/gallery/preview", async c => {
const service = c.get("service");
diff --git a/docs/README.md b/docs/README.md
index c67dc8e..f067044 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,18 +16,18 @@ then open [localhost:4786](http://127.0.0.1:4786).
## Build something
-Ask your agent to read `artifact_guide` for a React app or `script_guide` for an
-API, webhook, or scheduled job. These tools describe the APIs available in your
-running installation.
+Ask your agent to read `artifact_guide`, with topic `backend` for an API,
+webhook, or scheduled job. It describes the APIs available in your running
+installation.
| I need… | Guide |
| --- | --- |
-| An API, webhook, or scheduled job | [Scripts](scripts.md) |
+| An API, webhook, or scheduled job | [Endpoints](scripts.md) |
| A React app with a database or server-side secrets | [App backends and SQLite](app-backends.md) |
| File uploads and downloads | [File storage](files.md) |
| Multiple pages or HTTP API routes | [Routing](routing.md) |
| To copy a project to another installation | [Export and import](project-portability.md) |
-| Native queues, KV, R2, D1, or Durable Objects | [Native Worker apps](native-workers.md) |
+| Native queues, KV, R2, D1, or Durable Objects | [Native Workers](native-workers.md) |
## Run Artifacts for a team
diff --git a/docs/app-backends.md b/docs/app-backends.md
index 2806364..03fed2b 100644
--- a/docs/app-backends.md
+++ b/docs/app-backends.md
@@ -9,18 +9,27 @@ Ask your coding agent to read `artifact_guide` and create an app with a server,
or use the [counter example](../examples/counter.artifact.server.ts).
Browser code calls the backend through `artifactFetch`; each app has its own
persistent SQLite database. Saving or restoring source keeps the current data.
+An artifact can also have a server and no UI; that is an
+[endpoint](scripts.md), and it uses the same server contract.
## Server source and storage
A hosted artifact may contain two source snapshots: browser React source and an
-optional native server. Server source must export `ArtifactServer`, a Durable
-Object class from `cloudflare:workers`, with a normal `fetch(request)` method.
-Each artifact with a server gets one native SQLite database. Different artifact names
-get separate databases; multiple tabs and server restarts use the same database. Use `this.ctx.storage.sql.exec(sql, ...bindings)`
-with `?` placeholders for values, and `.toArray()` or `.one()` to read the cursor.
-Use `this.ctx.storage.kv.get/put/delete` for key/value data and
-`this.ctx.storage.transactionSync(() => { ... })` for synchronous SQL/KV transactions. Artifacts does
-not add a database abstraction or query-hook layer.
+optional server. Server source default-exports a Workers handler,
+`export default { fetch(request, env, ctx) }`, typed as
+`ExportedHandler`. Each artifact with a server gets one native SQLite
+database. Different artifact names get separate databases; multiple tabs and server
+restarts use the same database. Use `env.sql.exec(sql, ...bindings)` with `?`
+placeholders for values, and `.toArray()` or `.one()` to read the cursor. Use
+`env.storage.kv.get/put/delete` for key/value data and
+`env.storage.transactionSync(() => { ... })` for synchronous SQL/KV transactions.
+Artifacts does not add a database abstraction or query-hook layer.
+
+Servers written before handlers were supported export `ArtifactServer`, a Durable
+Object class from `cloudflare:workers`, with a normal `fetch(request)` method, and
+use `this.ctx.storage` and `this.env.secrets`. That form remains supported. Both
+forms run in the same Durable Object facet, so rewriting a class as a handler keeps
+the artifact's database.
### Application schema migrations
@@ -28,7 +37,7 @@ Artifacts does not provide a migration command or automatic application-schema r
The sample initializes tables with `create table if not exists`; that does not
upgrade an existing table. For schema changes, keep a schema version in the artifact
database, check it during server initialization, and apply pending changes together
-with the new version in one `this.ctx.storage.transactionSync`. Complete that work
+with the new version in one `env.storage.transactionSync`. Complete that work
before serving requests. Saving server source only stores and validates code;
initialization and migrations run when the updated server is next requested.
@@ -44,11 +53,14 @@ Generated servers do not receive the outer Worker's ordinary bindings. In
particular, do not assume D1, R2 or custom environment bindings are available.
The server supports outbound `fetch` and Workers-compatible `node:` imports.
Persistent application data uses the artifact Durable Object's own SQL/KV storage.
-The same CPU and subrequest limits as scripts apply on Cloudflare (30 seconds CPU,
-50 subrequests); the pinned celld runtime does not enforce these per-worker budgets.
-
-Declare `ArtifactServer extends DurableObject` to use
-`this.env.secrets.NAME`. Configure values in the gallery's **Secrets** tab or with
+Cloudflare limits each request to 30 seconds CPU and 50 subrequests; the pinned
+celld runtime does not enforce these per-worker budgets. Console output, request
+summaries and errors are kept as the latest 100 log entries; read them with
+`artifact_logs` or from a run in the gallery's run history. Secret values are
+redacted from captured output.
+
+Read secrets from `env.secrets.NAME` (`this.env.secrets.NAME` in an
+`ArtifactServer extends DurableObject` class). Configure values in the gallery's **Secrets** tab or with
`artifact_secrets({name, secrets: {API_TOKEN: "value", OLD_TOKEN: null}})`;
omitting `secrets` lists names only. Up to 32 keys, 4 KiB per value, and 32 KiB
total are supported. Secrets belong to the artifact, outside source history,
@@ -57,19 +69,17 @@ remixes and imports start empty. Secret updates reload the server without
resetting its database. Never include secrets in app responses.
```ts
-import { DurableObject } from "cloudflare:workers";
-
-export class ArtifactServer extends DurableObject {
- async fetch(request: Request): Promise {
+export default {
+ async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== "/api/customer") {
return Response.json({ error: "Not found" }, { status: 404 });
}
return fetch("https://api.example.com/customer", {
- headers: { authorization: `Bearer ${this.env.secrets.API_TOKEN}` },
+ headers: { authorization: `Bearer ${env.secrets.API_TOKEN}` },
});
- }
-}
+ },
+} satisfies ExportedHandler;
```
Direct `/slug/api/*` URLs preserve native HTTP streaming and support larger
diff --git a/docs/architecture.md b/docs/architecture.md
index 87d3d09..36da9e7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -26,10 +26,14 @@ turn it into the filesystem/stdio implementation.
`cloudflare/worker.ts` connects authenticated requests to the application.
`mcp.ts` owns the HTTP MCP transport and `tool-contract.ts` specializes the shared
schemas for hosted capabilities. `service.ts` coordinates artifact operations,
-artifact activation, artifact links, and the combined gallery. `script-service.ts`
-owns script reads, mutations, execution, secrets and logs; `script-guide.ts`
-contains its MCP authoring guidance. `artifact-service.ts` supplies the shared
-library/workspace identity and URL metadata used by both services.
+artifact activation, artifact links, and the combined gallery. An artifact's
+server is either a default-exported fetch handler, which `compiler.ts` wraps in the
+`ArtifactServer` Durable Object class, or that class itself; artifacts without a UI
+store empty browser source. `script-service.ts` owns legacy script reads,
+mutations, execution, secrets and logs; `script-guide.ts` contains its MCP
+authoring guidance. `server-logging.ts` is the console capture module loaded
+beside both artifact servers and legacy scripts. `artifact-service.ts` supplies
+the shared library/workspace identity and URL metadata used by both services.
`managed-service.ts` resolves authenticated library selections through the
ownership directory in the existing deployment `ArtifactLinks` Durable Object.
@@ -38,6 +42,10 @@ library/workspace/name identities. A move changes one ownership row, retaining
sources, revisions, projects, compiled code, runtime SQLite/KV, files, secrets,
schedules, execution history, and existing URLs. Legacy records retain their
original library owner until first moved; no data migration is required.
+Artifact operations that name a legacy script, and no artifact, are served from
+the script's own storage by `managed-service.ts`; scripts are never copied into
+artifact storage. Native Worker links name the app's permanent ID, and the root
+route asks the native controller for its current owner.
The directory serializes only admission and destination reservations. Compilation,
HTTP execution, and file operations run outside that queue. An operation admitted
diff --git a/docs/mcp-plugin.md b/docs/mcp-plugin.md
index 0716f6c..64e5469 100644
--- a/docs/mcp-plugin.md
+++ b/docs/mcp-plugin.md
@@ -30,7 +30,7 @@ The server builds on MCP Apps and advertises optional OpenAI extensions. Actual
| Surface | Purpose and boundary |
| --- | --- |
-| Global sidebar | Open the shared Artifacts gallery: folders, search, artifacts and scripts, Preview/Source, saved revisions, Share and Remix. Worker apps open the existing product. The catalog remains permission-filtered. |
+| Global sidebar | Open the shared Artifacts gallery: folders, search, artifacts, endpoints and legacy scripts, Preview/Source, saved revisions, Share and Remix. Native Workers open the existing product. The catalog remains permission-filtered. |
| Conversation view | Restore the explicit attached selection or choose an artifact from the library. Browsing does not attach context automatically. There is no stored host conversation identifier or separate conversation membership list. |
| Plugin settings | Native connection settings report the actual workspace and provide an authorized connection check. Endpoint configuration stays with the host; no fake editable preference is stored. |
| Mentions | Search accessible artifacts and scripts with workspace/kind identity; references resolve complete source projects. Mention results do not grant access. |
@@ -44,23 +44,23 @@ Tool visibility distinguishes model calls from app calls; it does not replace se
## Model tools and operation discovery
-Every connection advertises the same nine model tools: `artifact_guide`, `artifact_list`, `artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, `artifacts_discover`, `artifacts_query`, and `artifacts_execute`. Hosted connections retain all 48 artifact, script, file, schedule, secret, plugin and native Worker operations. Local file connections expose their supported subset. MCP Apps also receive the existing app-only gallery and host-integration tools.
+Every connection advertises the same nine model tools: `artifact_guide`, `artifact_list`, `artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, `artifacts_discover`, `artifacts_query`, and `artifacts_execute`. Hosted connections retain all 54 artifact, legacy script, Trash, file, schedule, log, secret, plugin and native Worker operations. Local file connections expose their supported subset. MCP Apps also receive the existing app-only gallery and host-integration tools.
Call `artifacts_discover` with `query` to search concise operation summaries, with neither selector to browse a paginated catalog, or with `operation` to retrieve its original JSON Schema, annotations, dispatch recommendation and relevant guide. Set `include_docs: false` after reading that guide. Discovery does not execute authored code, unlock tools or change `tools/list`; no connection or configuration switch is required.
-For example, inspect a script operation:
+For example, inspect a server operation:
```json
-{"operation":"script_write"}
+{"operation":"artifact_logs"}
```
Then call `artifacts_execute` with the discovered operation and its arguments:
```json
-{"operation":"script_write","arguments":{"name":"hello","contents":"export default { fetch() { return new Response('Hello'); } };"}}
+{"operation":"artifact_request","arguments":{"name":"hello","request":{"path":"/"}}}
```
-`artifacts_query` accepts the same envelope but enforces read-only behavior. Schedule inspection, secret-name listing and file list/download are permitted; schedule changes, secret values, uploads/deletes, backend HTTP requests, script runs and plugin calls require `artifacts_execute`. An HTTP GET to authored code still executes it. Unknown and app-only operations, recursive dispatch and arguments outside the original schema are rejected.
+`artifacts_query` accepts the same envelope but enforces read-only behavior. Schedule inspection, secret-name listing and file list/download are permitted; schedule changes, secret values, uploads/deletes, server HTTP requests, legacy script runs and plugin calls require `artifacts_execute`. An HTTP GET to authored code still executes it. Unknown and app-only operations, recursive dispatch and arguments outside the original schema are rejected.
Each dispatched call enters the original authorization and validation path and returns its original result, including inline previews, diagnostics and optimistic conflicts. Existing direct operation calls remain accepted for compatibility. Host tool grants for advanced actions apply to the shared `artifacts_execute` tool; operation discovery and UI visibility do not grant access or bypass owner/team checks.
diff --git a/docs/native-workers.md b/docs/native-workers.md
index abb1e7e..9e6fb84 100644
--- a/docs/native-workers.md
+++ b/docs/native-workers.md
@@ -1,24 +1,31 @@
-# Native Worker apps
+# Native Workers
-Use a native Worker app when you need platform resources such as queues, KV, R2,
-or D1 in addition to a request handler. For a React interface with SQLite, start
-with [app backends](app-backends.md); for an endpoint or scheduled job with SQLite,
-start with [scripts](scripts.md).
+Artifacts run on one of two runtimes. The managed runtime, used by default, runs an
+artifact's UI and server in a sandbox with SQLite, KV, secrets, files, schedules
+and logs already provided. A native Worker is the other runtime: you deploy an
+ordinary Cloudflare Worker module and declare the platform resources it needs,
+such as queues, KV, R2, D1 or Durable Objects. For a React interface with SQLite,
+start with [app backends](app-backends.md); for an endpoint or scheduled job with
+SQLite, start with [endpoints](scripts.md). Switching an existing artifact between
+runtimes does not move its data; managed SQLite does not become D1.
Your administrator must configure a [deployment provider](#providers) before you
-can deploy native apps. Then choose **New Worker app** from the **+** menu beside Artifacts, or ask your agent
-to read `app_guide`. Native app URLs are private; they do not currently support
-public links.
+can deploy native Workers. Then choose **New native Worker** from the **+** menu beside Artifacts, or ask your agent
+to read `app_guide`. The MCP operations keep their `app_*` names.
-Native apps deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the script SDK's `env.sql` wrapper. Worker apps appear alongside artifacts and scripts in the sidebar, with the same folders and search. Select one to edit its source, resources, triggers, deployments, and settings in the detail pane. The gallery and hosted MCP `app_*` tools use the same authenticated deployment controller.
+Native Workers deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the managed `env.sql` and `env.storage` wrappers. Native Workers appear alongside other artifacts in the sidebar, with a **Native** label and the same folders and search. Select one to edit its source, resources, triggers, deployments, and settings in the detail pane. The gallery and hosted MCP `app_*` tools use the same authenticated deployment controller.
-An app belongs to a private or team library and a workspace. Its UUID permanently owns provider resources; source revisions, export names, logical library moves and binding names do not change those identities. App URLs are private:
+An app belongs to a private or team library and a workspace. Its UUID permanently owns provider resources; source revisions, export names, logical library moves and binding names do not change those identities.
+
+Give a native Worker a root URL like any other artifact: use **Link** under its **Settings** in the gallery, or `artifact_link({kind:"app",name,slug,access})`. Slugs share the deployment-wide namespace with artifacts. Private links admit the app's current library, following it across library moves; public links admit any caller, and the app can implement its own authentication. The app receives paths relative to the slug, so `/my-app/jobs` arrives as `/jobs`. `app_read` and `app_list` report the link's `slug`, `access` and `url`.
+
+Every app also keeps its management URL, which is always private:
```text
/apps/my-app/?workspace=default&library=private
```
-The controller authenticates library access before proxying HTTP, removes management credentials/cookies and strips response cookies. The app receives its app-relative path and deployment origin. HTTP bodies stream and have no browser/MCP envelope limit. App URLs must include their workspace/library selectors on subsequent requests; clients should preserve these selectors when constructing links. This delivery does not assign public root slugs or provide an iframe application viewer.
+Both routes remove management credentials/cookies before proxying and strip response cookies. The app receives its app-relative path and deployment origin. HTTP bodies stream and have no browser/MCP envelope limit. Management URLs must include their workspace/library selectors on subsequent requests; clients should preserve these selectors when constructing links. Native Workers have no iframe application viewer.
## Manifest and source
diff --git a/docs/scripts.md b/docs/scripts.md
index a095883..ff8b14c 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -1,99 +1,110 @@
-# Scripts and direct artifact links
+# Endpoints, direct links and legacy scripts
-Use a script for an API endpoint, webhook, or scheduled job. A script is a
-TypeScript HTTP handler with its own SQLite database and secrets. It runs in the
-full Artifacts application, including `artifacts host` on your machine.
+Use an endpoint for an API, webhook, or scheduled job. An endpoint is an
+artifact without a UI: a TypeScript HTTP handler with its own SQLite database,
+secrets, logs and run history. It runs in the full Artifacts application,
+including `artifacts host` on your machine. Artifacts with a UI use the same
+server contract under `/api`; see [app backends](app-backends.md).
-Create one in the gallery with **New script**, or ask your coding agent to read
-`script_guide` and write it. Give it a URL such as `/stripe-webhook`, then choose
+Create one in the gallery with **New endpoint**, or ask your coding agent to
+read `artifact_guide` with topic `backend` and call `artifact_write` with
+`server` and no `contents`. Give it a URL such as `/stripe-webhook`, then choose
whether it is private or public. Saving valid source makes it live immediately.
-Opening a script URL runs its handler; selecting it in the gallery only opens
-its editor.
+Requests to the URL run its handler; selecting it in the gallery only opens its
+editor.
-- [Create and run a script](#gallery)
-- [Write a handler and store data](#script-handler)
+- [Create and run an endpoint](#gallery)
+- [Write a handler and store data](#server-handler)
- [Control who can call it](#access)
- [Add packages and helper files](#third-party-dependencies)
- [Schedule a run](#schedules-and-execution-history)
- [Copy or move an item](#remix)
+- [Scripts created before endpoints](#legacy-scripts)
-Apps can also have direct URLs, such as `/sales-dashboard`, which open their
-React interface and backend. URL slugs are unique across the deployment,
-including across item types and libraries; existing application routes are reserved.
+Artifacts with a UI can also have direct URLs, such as `/sales-dashboard`, which
+open their React interface and backend. Native Workers can have them too. URL
+slugs are unique across the deployment, including across item types and
+libraries; existing application routes are reserved.
## Gallery
-Choose **New script**, enter a name and URL slug, and edit the TypeScript source. Select **Private** or **Public**, then **Save script**. Scripts appear alongside artifacts in the library. Selecting a script loads its source without running it.
+Choose **New endpoint**, enter a name and URL slug, and edit the TypeScript source. Select **Private** or **Public**, then **Save and deploy**. Endpoints appear alongside other artifacts in the library with an **Endpoint** label; the **Endpoints** filter shows them together with legacy scripts. Selecting an endpoint loads its source without running it.
-For either artifact, the **URL /** field and access selector control its direct link. **Save link** updates the settings; **Open** visits the URL and **Copy URL** copies its absolute address. Opening a script URL sends a GET request and runs its handler. Updating the source keeps the chosen URL. Changing the slug changes the URL; old URLs are not aliases.
+For any artifact, the **URL /** field and access selector control its direct link. **Save link** updates the settings; **Open** visits the URL and **Copy URL** copies its absolute address. Updating the source keeps the chosen URL. Changing the slug changes the URL; old URLs are not aliases.
-The script editor's **Save script** validates and saves the working source. A successful update serves immediately, without a separate publishing step. A failed validation keeps the previous working revision and access settings serving. If the first save is invalid, its chosen slug is retained with the draft until a valid correction creates the URL. The **Version** selector lets you read older revisions; **Restore revision** makes the selected revision current. **Download source** downloads the selected source.
+**Save and deploy** validates and saves the working source. A successful update serves immediately, without a separate publishing step. A failed validation keeps the previous working revision and access settings serving. If the first save is invalid, its chosen slug is retained with the draft until a valid correction creates the URL. The **Versions** menu lets you read older revisions; **Restore and deploy** makes the selected revision current. **Download** saves the source project.
-Expand **Run** to choose an HTTP method, path, headers as a JSON object, and a text body for methods other than GET and HEAD. **Run script** invokes the current saved script, even when you are browsing a historical revision or have unsaved edits. The response displays its status, headers, and decoded text body. Binary bodies are shown as base64. Returned HTML is displayed as text in the runner.
+Open **Requests** to choose an HTTP method, a path relative to the endpoint URL, headers as a JSON object, and a text body for methods other than GET and HEAD. **Send request** invokes the current saved revision, even when you are browsing a historical revision or have unsaved edits. The response displays its status, headers, and decoded text body. Binary bodies are shown as base64. Returned HTML is displayed as text.
-Expand **Logs** and select **Load logs** to inspect recent execution output. Under **Secrets**, enter a name and value and select **Save secret**; the value field clears after saving. Enter a name and select **Remove secret** to delete it. Stored secret values are never loaded into the editor or source history.
+**Schedule** in the More actions menu configures recurring runs and shows run history; each run links to its logs. Under **Settings**, enter a name and value and select **Save secret**; the value field clears after saving. Stored secret values are never loaded into the editor or source history.
-These controls are available on Cloudflare, celld, and `artifacts host`. The file-based `artifacts web` gallery previews React artifacts and does not execute scripts.
+These controls are available on Cloudflare, celld, and `artifacts host`. The file-based `artifacts web` gallery previews React artifacts and does not execute servers.
-## Script handler
+## Server handler
-A script implements a standard Workers fetch handler. It can return JSON, HTML, binary data, redirects, or any other HTTP response supported by the runtime. Webhooks are one application of this contract.
+A server implements a standard Workers fetch handler. It can return JSON, HTML, binary data, redirects, or any other HTTP response supported by the runtime. Webhooks are one application of this contract.
```ts
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
- if (url.pathname.endsWith("/health")) {
+ if (url.pathname === "/health") {
return Response.json({ ok: true });
}
return new Response("Hello", {
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
-} satisfies ExportedHandler;
+} satisfies ExportedHandler;
```
-`ScriptEnv` is available during type checking. It contains `secrets: Record` and `sql: SqlStorage`. Outbound `fetch` is available. `ctx.waitUntil` can extend background work associated with a request. Scripts can import supported `cloudflare:` and `node:` builtins. Use relative helper modules and exact npm dependencies through the project source editor or MCP project fields. Code must be compatible with the Workers runtime. Use [schedules](#schedules-and-execution-history) for recurring runs.
+`ServerEnv` is available during type checking. It contains `secrets: Record`, `sql: SqlStorage`, and `storage` with `sql`, `kv` and `transactionSync`. Outbound `fetch` is available. `ctx.waitUntil` can extend background work associated with a request. Servers can import supported `cloudflare:` and `node:` builtins. Use relative helper modules and exact npm dependencies through the project source editor or MCP project fields. Code must be compatible with the Workers runtime. Use [schedules](#schedules-and-execution-history) for recurring runs.
-Use `env.secrets.NAME` for a configured secret. For example, a handler can verify a provider signature against the original request body. Script HTTP requests preserve their methods, query strings, and body bytes. The gateway strips cookies and Cloudflare Access assertion/service-token headers; private routes also strip the authorization credential used for management authentication.
+An endpoint receives every path under its URL, relative to the slug: a request to `/stripe-webhook/events?id=1` reaches the handler as `/events?id=1`, and `/stripe-webhook` itself arrives as `/`. Requests preserve their methods, query strings, and body bytes. The gateway strips cookies and Cloudflare Access assertion/service-token headers; private routes also strip the authorization credential used for management authentication. Paths under `//_artifact/` are reserved.
-Each script has its own persistent SQLite storage. The database survives source updates and revision restores:
+Use `env.secrets.NAME` for a configured secret. For example, a handler can verify a provider signature against the original request body.
+
+Each artifact has its own persistent SQLite storage. The database survives source updates and revision restores:
```ts
export default {
async fetch(request, env) {
env.sql.exec("create table if not exists visits (at text not null)");
- env.sql.exec("insert into visits values (?)", new Date().toISOString());
+ env.storage.transactionSync(() => {
+ env.sql.exec("insert into visits values (?)", new Date().toISOString());
+ });
const row = env.sql.exec<{ count: number }>(
"select count(*) as count from visits",
).one();
return Response.json(row);
},
-} satisfies ExportedHandler;
+} satisfies ExportedHandler;
```
-Do not assume an in-memory global persists between requests or updates. On Cloudflare, each execution is limited to 30 seconds of CPU and 50 subrequests, in addition to the Workers runtime limits. The celld configuration omits these per-script budgets because the pinned celld runtime does not enforce them. Logs retain the latest 100 entries with messages capped at 2 KiB. Secret strings are redacted from captured console output; handlers still control their own response bodies and outbound requests.
+Do not assume an in-memory global persists between requests or updates. On Cloudflare, each execution is limited to 30 seconds of CPU and 50 subrequests, in addition to the Workers runtime limits. The celld configuration omits these per-request budgets because the pinned celld runtime does not enforce them. Logs retain the latest 100 entries with messages capped at 2 KiB. Secret strings are redacted from captured console output; handlers still control their own response bodies and outbound requests.
+
+A server can also export `class ArtifactServer extends DurableObject` from `cloudflare:workers`, using `this.ctx.storage` and `this.env.secrets`. Both forms use the same database, so existing class-based servers keep their data when rewritten as handlers.
## Access
For provider setup, MCP OAuth, and the credentials accepted by private URLs, see
[authentication and access](authentication.md).
-Links default to **Private** and use the artifact's existing library permissions. Management APIs and the gallery remain authenticated. Setting a link to **Public** allows external callers to invoke or view it; a public script can implement its own authentication through request headers or signatures.
+Links default to **Private** and use the artifact's existing library permissions. Management APIs and the gallery remain authenticated. Setting a link to **Public** allows external callers to invoke or view it; a public endpoint can implement its own authentication through request headers or signatures, and accepts requests from other origins like any public API.
If Cloudflare Access protects the deployment's entire hostname, configure an Access bypass for the intended public paths so external callers can reach the Worker. The artifact's access setting does not override an Access policy that blocks the request before it reaches the application.
-HTML served at artifact URLs is sandboxed with a response Content Security Policy to isolate it from the management origin. Script responses cannot set cookies through these routes. This allows HTML handlers while keeping the gallery session separate.
+HTML served at artifact URLs is sandboxed with a response Content Security Policy to isolate it from the management origin. Server responses cannot set cookies through these routes. This allows HTML handlers while keeping the gallery session separate.
## MCP
-Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_history`, `script_restore`, `script_run`, `script_logs`, and `script_secrets` to manage scripts. `artifact_link` sets the slug and access for either `kind: "artifact"` or `kind: "script"`. Creation and link results include the resulting URL.
+Agents use `artifact_write` with `server` and no `contents` to create an endpoint, then `artifact_read`, `artifact_edit`, `artifact_history`, `artifact_restore`, `artifact_request`, `artifact_logs`, `artifact_runs`, `artifact_schedule`, and `artifact_secrets` to manage it. `artifact_read` and `artifact_edit` select the server by default when an artifact has no UI. `artifact_link` sets the slug and access for `kind: "artifact"`, a legacy `kind: "script"`, or a native Worker with `kind: "app"`. Creation and link results include the resulting URL.
-`script_run` takes a request object with `path`, `method`, `headers` as an array of `[name, value]` pairs, and an optional base64 `body`. Its structured result contains `response` with `status`, `statusText`, `headers`, and an optional base64 `body`. For example:
+`artifact_request` takes a request object with `path` (relative to the artifact URL), `method`, `headers` as an array of `[name, value]` pairs, and an optional base64 `body`. Its structured result contains `response` with `status`, `statusText`, `headers`, and an optional base64 `body`. For example:
```json
{
- "name": "script_run",
+ "name": "artifact_request",
"arguments": {
"name": "echo",
"request": {
@@ -106,9 +117,11 @@ Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_
}
```
+Adding `contents` later gives the artifact a UI; the server then answers only under `/api`. Passing `contents: null` removes the UI again.
+
## Third-party dependencies
-Scripts and artifacts accept an optional `project` alongside their existing entrypoint source:
+Artifacts and legacy scripts accept an optional `project` alongside their entrypoint source:
```json
{
@@ -117,17 +130,17 @@ Scripts and artifacts accept an optional `project` alongside their existing entr
}
```
-Import helpers with relative paths such as `./lib/message`, and import packages normally. The gallery source editor provides a file picker, add/remove helper controls, and an exact-version dependency editor. `script_read`, `script_edit`, `artifact_read`, and `artifact_edit` accept `file` to target a helper. Omit `project` on write to retain it; when supplied, its files and dependency declarations replace the whole project. Read responses include the complete project snapshot. Historical views are read-only and restoring a revision restores every source file and its dependency lock.
+Import helpers with relative paths such as `./lib/message`, and import packages normally. The gallery source editor provides a file picker, add/remove helper controls, and an exact-version dependency editor. `artifact_read` and `artifact_edit` (and the legacy `script_read` and `script_edit`) accept `file` to target a helper. Omit `project` on write to retain it; when supplied, its files and dependency declarations replace the whole project. Read responses include the complete project snapshot. Historical views are read-only and restoring a revision restores every source file and its dependency lock.
Dependency declarations accept exact npm versions only. The service resolves packages when declarations change, verifies tarball integrity, and archives the package source and types with the artifact revision. Edits with unchanged dependencies, replay, and execution use this archived snapshot without registry access. Package installation scripts never execute. Download/decompression, file counts, and stored content are bounded. The service only accesses the public npm registry and rejects arbitrary tarball origins.
The compiler currently supports one version of each package. Conflicting transitive requirements and missing required peers produce explicit diagnostics. Browser React, ReactDOM and React Router dependencies use Artifacts' own compatible runtime so hooks do not load a second React. Packages must support the browser or Workers environment; native addons and packages requiring a Node process are unsupported. Source archives contain up to 64 helper files (1 MiB total) and up to 64 resolved packages with a dependency lock bounded to 4,096 files and 8 MiB. The entrypoint remains limited to 256 KiB.
-Hosted Cloudflare and celld deployments use the same project contract. Local artifact CLI and MCP also persist the project beside the artifact and carry its files and dependency snapshot through history and remix. Use `artifacts write NAME --file SOURCE --project PROJECT_JSON`, where the JSON contains files and exact dependency declarations, or pass `project` to local MCP `artifact_write`. Source-only local writes preserve the project; update helpers by replacing `project.files`. Standalone script tools remain hosted-only.
+Hosted Cloudflare and celld deployments use the same project contract. Local artifact CLI and MCP also persist the project beside the artifact and carry its files and dependency snapshot through history and remix. Use `artifacts write NAME --file SOURCE --project PROJECT_JSON`, where the JSON contains files and exact dependency declarations, or pass `project` to local MCP `artifact_write`. Source-only local writes preserve the project; update helpers by replacing `project.files`. Servers and the legacy script tools remain hosted-only.
-Agents can call `script_guide` to retrieve the authoring contract through MCP.
+Agents can call `artifact_guide` with topic `backend` to retrieve the server contract through MCP.
-Hosted `artifact_read`, `script_read`, and JSON source snapshots include a
+Hosted `artifact_read`, legacy `script_read`, and JSON source snapshots include a
`revision_token` for the complete project. Pass it as `expected_revision` on a
full write to reject changes made since the read, including backend, helper and
dependency changes. `expected_revision: null` requires a new name. Omit the field
@@ -138,36 +151,44 @@ explicit reload; your edits remain available until you choose to discard them.
## Remix
-Select a working copy or historical revision in the gallery, then **Remix** and choose a new name. MCP offers `artifact_remix` and `script_remix`, with `new_name` and exactly one of `name` or `version_id`. Hosted calls optionally accept a new `slug`; otherwise it defaults to the destination name. Sources and destinations belong to the authenticated library and selected workspace.
+Select a working copy or historical revision in the gallery, then **Remix** and choose a new name. MCP offers `artifact_remix`, with `new_name` and exactly one of `name` or `version_id`; remixing a [legacy script](#legacy-scripts) creates a server-only artifact. Hosted calls optionally accept a new `slug`; otherwise it defaults to the destination name. Sources and destinations belong to the authenticated library and selected workspace.
A remix creates a separate artifact and records its immutable source revision as provenance. Artifact browser and server sources stay paired. The new artifact starts with empty runtime data and no copied secret values; its hosted URL starts private. Destination names cannot overwrite existing drafts or archived artifacts, and URL collisions are rejected. Invalid source remains saved as a remix draft, with validation diagnostics; it receives a working URL only after successful validation. Remixing does not execute the request handler.
## Schedules and execution history
-Hosted Cloudflare and celld servers can schedule scripts and artifact backends with native Durable Object alarms. Configure **Schedule** in the gallery, or call `script_schedule` / `artifact_schedule` with `name` and `action: "set"`. Supply exactly one of `interval_seconds` (at least 60) or a five-field `cron` expression. Cron accepts an IANA `timezone`, defaulting to `UTC`. Supply a relative HTTP `request` (path, method, headers, optional base64 body); the default is GET `/`.
+Hosted Cloudflare and celld servers can schedule artifact servers, including endpoints and legacy scripts, with native Durable Object alarms. Configure **Schedule** in the gallery, or call `artifact_schedule` with `name` and `action: "set"`. Supply exactly one of `interval_seconds` (at least 60) or a five-field `cron` expression. Cron accepts an IANA `timezone`, defaulting to `UTC`. Supply a relative HTTP `request` (path, method, headers, optional base64 body); the default is GET `/`.
-Use `action: "get"`, `"pause"`, `"resume"`, or `"run_now"` to inspect or control the schedule. Run-now executes the saved request, including while paused. Each artifact has one schedule. Removing an artifacts server pauses its schedule. The local filesystem CLI/stdio runtime does not execute backends or run a scheduler; use `artifacts server` for local scheduled execution.
+Use `action: "get"`, `"pause"`, `"resume"`, or `"run_now"` to inspect or control the schedule. Run-now executes the saved request, including while paused. Each artifact has one schedule. Removing an artifact's server pauses its schedule. The local filesystem CLI/stdio runtime does not execute backends or run a scheduler; use `artifacts server` for local scheduled execution.
Schedules execute the latest validated revision, not an invalid draft or a historical preview. An occurrence is durably claimed before calling user code; missed intervals are skipped rather than accumulated. Failures and uncertain interrupted invocations are not automatically replayed. Application handlers should make repeated writes idempotent when that matters. A local celld server must be running for alarms to execute.
-`script_runs` and `artifact_runs` expose the latest 100 of up to 1,000 retained executions: revision, trigger, start/end times, duration, HTTP status and outcome. The gallery's **Run history** shows the same records. These records live in host-owned SQLite, separate from the user handler's database, and survive code updates and source restores. An `interrupted` record means completion is unknown and side effects may already have occurred.
+`artifact_runs` exposes the latest 100 of up to 1,000 retained executions: revision, trigger, start/end times, duration, HTTP status and outcome. The gallery's **Run history** shows the same records. These records live in host-owned SQLite, separate from the user handler's database, and survive code updates and source restores. An `interrupted` record means completion is unknown and side effects may already have occurred.
-Script logs can be filtered by `run_id` from `script_runs`; the existing 100-entry log retention still applies. Script success describes the HTTP handler returning a response, not completion of `waitUntil` tasks or streamed response bodies. Artifact history includes response serialization; artifact console capture is not provided.
+`artifact_logs` returns the latest 100 console lines, request summaries and errors, and can be filtered by `run_id` from `artifact_runs`. Success describes the HTTP handler returning a response, not completion of `waitUntil` tasks or streamed response bodies. Requests from a UI through `artifactFetch` or `artifact_request` also include response serialization.
## Moving between libraries
In the hosted gallery, **Move to team** changes an item's owner from your personal
library to the deployment's team. **Move to personal** moves a team item into the
signed-in user's personal library, removing other team members' management access.
-Both artifacts and scripts retain their complete source history, helper modules,
+Artifacts and legacy scripts retain their complete source history, helper modules,
backend data, files, secrets, schedules, and existing URLs. The URL's **Private** or
**Public** setting is separate and remains unchanged. Existing destination names
cause a conflict; moves never replace another item.
The authenticated HTTP operation is
`POST /api/library/move?library=private&workspace=default` with
-`{"kind":"script","name":"handler","library":"team"}`. The query selects the
-current owner and the body selects the destination. Use `kind: "artifact"` for an
-artifact, and swap `private`/`team` for the reverse direction. A personal destination
+`{"kind":"artifact","name":"handler","library":"team"}`. The query selects the
+current owner and the body selects the destination. Use `kind: "script"` for a
+legacy script, and swap `private`/`team` for the reverse direction. A personal destination
is always the caller's own library. Operations admitted before a move may finish,
and existing file transfer grants remain valid until their usual expiry.
+
+## Legacy scripts
+
+Before endpoints, HTTP handlers were a separate item type created with `script_write` or the gallery's former **New script** command. Existing scripts keep their storage, secrets, schedules, run history and URLs; nothing is migrated. They appear in the library with a **Legacy script** label, and in `artifact_list` results as `legacy_scripts`.
+
+`script_write`, `script_read`, `script_edit`, `script_list`, `script_history`, `script_version`, `script_restore`, `script_remix`, `script_run`, `script_logs`, `script_runs`, `script_schedule`, `script_secrets`, `script_export`, `script_import` and `script_guide` keep their contracts, including creating new scripts. Agents do not need them: when no artifact has the requested name, `artifact_read`, `artifact_edit`, `artifact_write` (with `server` only), `artifact_history`, `artifact_version`, `artifact_restore`, `artifact_export`, `artifact_request`, `artifact_runs`, `artifact_logs`, `artifact_schedule` and `artifact_secrets` operate on the legacy script. A legacy script cannot gain a UI, and a new artifact cannot take a legacy script's name.
+
+A script handler receives the full deployment path, such as `/stripe-webhook/events`, and `ScriptEnv` provides `secrets` and `sql`. To move a script onto the artifact runtime, remix it: `artifact_remix` with the script's `name` or `version_id` (or **Remix** in the gallery) creates a server-only artifact from its source, which compiles unchanged because `ScriptEnv` is accepted as an alias of `ServerEnv`. The copy starts with empty storage, no secrets, no schedule and a private URL, and its handler receives paths relative to its own URL, so adjust any code that matches on the slug. `artifact_import` also accepts script archives from `script_export` and creates server-only artifacts.
diff --git a/e2e/gallery-creation.test.ts b/e2e/gallery-creation.test.ts
index 8e55639..1360381 100644
--- a/e2e/gallery-creation.test.ts
+++ b/e2e/gallery-creation.test.ts
@@ -104,14 +104,14 @@ test("one creation menu preserves artifact drafts and retries saved invalid host
const create = page.getByRole("button", { name: "Create or import", exact: true });
await create.click();
await page.getByRole("menu").waitFor();
- expect(await page.getByRole("menuitem").allTextContents()).toEqual(["New artifact", "New script", "New Worker app", "Import project"]);
+ expect(await page.getByRole("menuitem").allTextContents()).toEqual(["New artifact", "New endpoint", "New native Worker", "Import project"]);
const heading = await page.locator(".library-heading").boundingBox();
const search = await page.getByRole("searchbox").boundingBox();
const plus = await create.boundingBox();
expect(heading!.y + heading!.height).toBeLessThanOrEqual(search!.y);
expect(plus!.x).toBeGreaterThan(search!.x);
expect(plus!.x + plus!.width).toBeLessThanOrEqual(search!.x + search!.width + 4);
- expect(await page.getByRole("button", { name: "New Worker app", exact: true }).count()).toBe(0);
+ expect(await page.getByRole("button", { name: "New native Worker", exact: true }).count()).toBe(0);
expect(await page.getByText("Artifacts and scripts", { exact: true }).count()).toBe(0);
await screenshot(page, "create-menu-desktop");
await page.keyboard.press("Escape");
diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts
index ca7e748..82cf432 100644
--- a/e2e/gallery-drafts.test.ts
+++ b/e2e/gallery-drafts.test.ts
@@ -48,15 +48,15 @@ test("gallery retains complete unsaved projects and new scripts and offers confl
await page.getByTitle("default/handler", { exact: true }).click();
await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unsaved script");
await page.getByRole("button", { name: "Create or import", exact: true }).click();
- await page.getByRole("menuitem", { name: "New script", exact: true }).click();
- await page.getByRole("textbox", { name: "Script name", exact: true }).fill("unfinished-handler");
- await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unfinished new script");
+ await page.getByRole("menuitem", { name: "New endpoint", exact: true }).click();
+ await page.getByRole("textbox", { name: "Endpoint name", exact: true }).fill("unfinished-handler");
+ await page.getByRole("textbox", { name: "unfinished-handler.artifact.server.ts", exact: true }).fill("unfinished new endpoint");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unsaved script");
await page.getByRole("button", { name: "Create or import", exact: true }).click();
- await page.getByRole("menuitem", { name: "New script", exact: true }).click();
- expect(await page.getByRole("textbox", { name: "Script name", exact: true }).inputValue()).toBe("unfinished-handler");
- expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unfinished new script");
+ await page.getByRole("menuitem", { name: "New endpoint", exact: true }).click();
+ expect(await page.getByRole("textbox", { name: "Endpoint name", exact: true }).inputValue()).toBe("unfinished-handler");
+ expect(await page.getByRole("textbox", { name: "unfinished-handler.artifact.server.ts", exact: true }).innerText()).toBe("unfinished new endpoint");
await page.getByTitle("default/report", { exact: true }).click();
expect(await client.innerText()).toBe("unsaved client");
await page.getByRole("button", { name: "report.artifact.server.ts", exact: true }).click();
diff --git a/e2e/gallery-move.test.ts b/e2e/gallery-move.test.ts
index c7e401a..52b3edf 100644
--- a/e2e/gallery-move.test.ts
+++ b/e2e/gallery-move.test.ts
@@ -60,7 +60,7 @@ test("gallery moves artifacts and scripts between libraries, preserves selection
const selected = page.locator('.artifact-row[aria-current="true"]');
await selected.waitFor();
expect(await selected.getAttribute("title")).toBe(`${workspace}/${name}`);
- expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nScript` : name);
+ expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nLegacy script` : name);
expect(await page.getByRole("combobox", { name: "Library", exact: true }).inputValue()).toBe("private");
await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("menuitem", { name: "Move", exact: true }).click();
const panel = page.getByRole("form", { name: "Move between libraries" });
@@ -101,7 +101,7 @@ test("gallery moves artifacts and scripts between libraries, preserves selection
expect(await page.getByRole("combobox", { name: "Library", exact: true }).inputValue()).toBe(destination);
expect(Object.fromEntries(new URL(page.url()).searchParams)).toEqual({ library: destination, workspace, name, kind, tab: "source" });
expect(await selected.getAttribute("title")).toBe(`${workspace}/${name}`);
- expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nScript` : name);
+ expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nLegacy script` : name);
expect(moves.at(-1)).toEqual({ from, workspace, kind, name, library: destination });
expect(libraries[from].some(artifact => artifact.kind === kind && artifact.workspace === workspace)).toBe(false);
expect(libraries[destination].some(artifact => artifact.kind === kind && artifact.workspace === workspace)).toBe(true);
diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts
index 65c6a91..fe605d3 100644
--- a/e2e/gallery-native-apps.test.ts
+++ b/e2e/gallery-native-apps.test.ts
@@ -58,7 +58,7 @@ test("Workers share the library folders, search, selection and mobile navigation
expect(await report.count()).toBe(0);
await search.fill("");
const filters = library.getByRole("group", { name: "Filter library" });
- await filters.getByRole("button", { name: "Workers", exact: true }).click();
+ await filters.getByRole("button", { name: "Native", exact: true }).click();
expect(await report.count()).toBe(0);
expect(await library.locator(".artifact-row").count()).toBe(2);
await filters.getByRole("button", { name: "All", exact: true }).click();
@@ -68,7 +68,7 @@ test("Workers share the library folders, search, selection and mobile navigation
const artifactSource = page.getByRole("textbox", { name: "Report.artifact.tsx", exact: true });
await artifactSource.fill("export default function Report() { return
Unsaved report
; }");
await worker.click();
- const panel = page.getByRole("region", { name: "Worker apps", exact: true });
+ const panel = page.getByRole("region", { name: "Native Workers", exact: true });
const source = panel.getByRole("textbox", { name: "worker.ts", exact: true });
await source.waitFor();
expect(await source.innerText()).toContain('"Jobs"');
@@ -244,12 +244,12 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(server.url.href);
const library = page.getByRole("complementary");
- expect(await page.locator(".header-actions").getByRole("button", { name: "Worker apps", exact: true }).count()).toBe(0);
+ expect(await page.locator(".header-actions").getByRole("button", { name: "Native Workers", exact: true }).count()).toBe(0);
await page.getByRole("button", { name: "Create or import", exact: true }).click();
- await page.getByRole("menuitem", { name: "New Worker app", exact: true }).click();
- const panel = page.getByRole("region", { name: "Worker apps", exact: true });
+ await page.getByRole("menuitem", { name: "New native Worker", exact: true }).click();
+ const panel = page.getByRole("region", { name: "Native Workers", exact: true });
expect(await library.isVisible()).toBe(true);
- await panel.getByLabel("Worker app name").fill("example");
+ await panel.getByLabel("Native Worker name").fill("example");
await panel.getByRole("button", { name: "Continue to editor" }).click();
await panel.getByRole("tab", { name: "Settings", exact: true }).click();
await panel.getByText("Advanced manifest (JSON)", { exact: true }).click();
@@ -266,7 +266,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi
},
triggers: { crons: ["0 * * * *"], queues: ["jobs"] },
};
- await panel.getByLabel("Worker app manifest").fill(JSON.stringify(fullManifest));
+ await panel.getByLabel("Native Worker manifest").fill(JSON.stringify(fullManifest));
await panel.getByLabel("Variable value 1", { exact: true }).fill("production");
await panel.getByRole("tab", { name: "Resources", exact: true }).click();
await panel.getByLabel("Resource name 1", { exact: true }).fill("stable_store");
@@ -283,7 +283,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi
triggers: { crons: ["30 * * * *"], queues: ["jobs"] },
});
await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click();
- await panel.getByText("Worker app deployed", { exact: true }).waitFor();
+ await panel.getByText("Native Worker deployed", { exact: true }).waitFor();
await library.getByTitle("test/example", { exact: true }).waitFor();
expect(await library.getByTitle("test/example", { exact: true }).getAttribute("aria-current")).toBe("true");
expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken);
@@ -336,7 +336,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi
expect(await panel.locator(".cm-content").innerText()).toContain("restored draft");
expect(await panel.getByText("Unsaved changes", { exact: true }).count()).toBe(0);
await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click();
- await panel.getByText("Worker app deployed", { exact: true }).waitFor();
+ await panel.getByText("Native Worker deployed", { exact: true }).waitFor();
expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(restoredToken);
expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.source).toContain("restored draft");
await panel.getByRole("tab", { name: "Deployments", exact: true }).click();
@@ -394,10 +394,10 @@ test("a newly configured fleet provider enables Worker creation without reloadin
try {
const page = await browser.newPage();
await page.goto(`${server.url.href}?view=workers`);
- await page.getByRole("heading", { name: "Worker apps are unavailable" }).waitFor();
+ await page.getByRole("heading", { name: "Native Workers are unavailable" }).waitFor();
configured = true;
await page.evaluate(() => window.dispatchEvent(new Event("online")));
- await page.getByLabel("Worker app name").fill("fleet-app");
+ await page.getByLabel("Native Worker name").fill("fleet-app");
await page.getByRole("button", { name: "Continue to editor", exact: true }).click();
await page.getByRole("textbox", { name: "worker.ts", exact: true }).waitFor();
expect(await page.getByRole("button", { name: "Save and deploy Worker", exact: true }).isEnabled()).toBe(true);
@@ -450,12 +450,12 @@ test("Worker provider empty state has documentation and no creation editor", asy
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${server.url.href}?workspace=test&library=team&view=workers`);
- const panel = page.getByRole("region", { name: "Worker apps", exact: true });
- await panel.getByRole("heading", { name: "Worker apps are unavailable" }).waitFor();
+ const panel = page.getByRole("region", { name: "Native Workers", exact: true });
+ await panel.getByRole("heading", { name: "Native Workers are unavailable" }).waitFor();
expect(await panel.getByRole("link", { name: "Worker setup documentation" }).getAttribute("href")).toBe(
"https://github.com/sidequery/artifacts/blob/main/docs/native-workers.md",
);
- expect(await panel.getByLabel("Worker app name").count()).toBe(0);
+ expect(await panel.getByLabel("Native Worker name").count()).toBe(0);
expect(await panel.locator(".cm-content").count()).toBe(0);
await page.goto(`${server.url.href}?workspace=test&library=team&view=workers&app=unavailable-app&appTab=source`);
await panel
diff --git a/e2e/gallery-navigation.test.ts b/e2e/gallery-navigation.test.ts
index 68fa86a..763909a 100644
--- a/e2e/gallery-navigation.test.ts
+++ b/e2e/gallery-navigation.test.ts
@@ -108,11 +108,11 @@ test("gallery filters, previews revisions, and preserves mobile library navigati
expect(await script.isVisible()).toBe(true);
const filters = desktop.getByRole("group", { name: "Filter library" });
- await filters.getByRole("button", { name: "Artifacts", exact: true }).click();
+ await filters.getByRole("button", { name: "With UI", exact: true }).click();
await script.waitFor({ state: "hidden" });
expect(await campaign.isVisible()).toBe(true);
expect(await revenue.isVisible()).toBe(true);
- await filters.getByRole("button", { name: "Scripts", exact: true }).click();
+ await filters.getByRole("button", { name: "Endpoints", exact: true }).click();
await script.waitFor();
expect(await campaign.count()).toBe(0);
expect(await revenue.count()).toBe(0);
@@ -191,7 +191,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati
await screenshot(mobile, "gallery-mobile-library");
const mobileSearch = mobile.getByRole("searchbox", { name: "Search artifacts and scripts" });
await mobileSearch.fill("campaign");
- await mobile.getByRole("group", { name: "Filter library" }).getByRole("button", { name: "Artifacts", exact: true }).click();
+ await mobile.getByRole("group", { name: "Filter library" }).getByRole("button", { name: "With UI", exact: true }).click();
await mobileLibrary.getByTitle("default/Campaign overview", { exact: true }).click();
await mobile.getByRole("heading", { level: 1, name: "Campaign overview", exact: true }).waitFor();
expect(await mobileLibrary.isVisible()).toBe(false);
@@ -222,7 +222,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati
await mobileLibrary.waitFor();
expect(await mobileSearch.inputValue()).toBe("campaign");
expect(await mobileLibrary.getByTitle("default/Campaign overview", { exact: true }).evaluate(element => element === document.activeElement)).toBe(true);
- expect(await mobile.getByRole("group", { name: "Filter library" }).getByRole("button", { name: "Artifacts", exact: true }).getAttribute("aria-pressed")).toBe("true");
+ expect(await mobile.getByRole("group", { name: "Filter library" }).getByRole("button", { name: "With UI", exact: true }).getAttribute("aria-pressed")).toBe("true");
expect(await mobileLibrary.getByTitle("default/Campaign overview", { exact: true }).getAttribute("aria-current")).toBe("true");
expect(await mobile.getByRole("heading", { level: 1, name: "Campaign overview", exact: true }).isVisible()).toBe(false);
await mobile.getByRole("button", { name: "Clear search", exact: true }).click();
diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts
index f5c600c..a7c2372 100644
--- a/e2e/gallery-scripts.test.ts
+++ b/e2e/gallery-scripts.test.ts
@@ -26,7 +26,8 @@ test("gallery edits scripts without execution and runs requests only on demand",
if (url.pathname === "/api/tools") {
const call = await request.json() as typeof calls[number]; calls.push(call);
if (call.name === "script_write" && call.arguments.name !== "hello") artifacts.push({ key: "script:new", kind: "script", name: call.arguments.name, workspace: "default", working: true, versions: [] });
- if (call.name === "script_remix") artifacts.push({key:"script:remixed",kind:"script",name:call.arguments.new_name,workspace:"default",working:true,versions:[]});
+ if (call.name === "artifact_write" && call.arguments.server !== undefined) artifacts.push({ key: "endpoint:new", kind: "artifact", serverOnly: true, name: call.arguments.name, workspace: "default", working: true, versions: [] });
+ if (call.name === "artifact_remix") artifacts.push({key:"endpoint:remixed",kind:"artifact",serverOnly:true,name:call.arguments.new_name,workspace:"default",working:true,versions:[]});
if (call.name === "artifact_link") return Response.json({ error: "Slug is already in use" }, { status: 409 });
return Response.json({ structuredContent: { response: { status: 200, statusText: "OK", headers: [["content-type", "text/plain"]], body: Buffer.from(" 🌍").toString("base64") } } });
}
@@ -64,27 +65,28 @@ test("gallery edits scripts without execution and runs requests only on demand",
await page.getByRole("button", { name: "Save link", exact: true }).click();
await page.getByRole("status").filter({ hasText: "Slug is already in use" }).waitFor();
await page.getByRole("button", { name: "Create or import", exact: true }).click();
- await page.getByRole("menuitem", { name: "New script", exact: true }).click();
- await page.getByRole("textbox", { name: "Script name", exact: true }).fill("new-handler");
- await page.getByRole("textbox", { name: "Script slug" }).fill("new-handler");
+ await page.getByRole("menuitem", { name: "New endpoint", exact: true }).click();
+ await page.getByRole("textbox", { name: "Endpoint name", exact: true }).fill("new-handler");
+ await page.getByRole("textbox", { name: "Endpoint slug" }).fill("new-handler");
await page.getByRole("button", { name: "Save and deploy", exact: true }).click();
- await page.getByRole("button", { name: "new-handler Script", exact: true }).waitFor();
- expect(await page.getByRole("button", { name: "new-handler Script", exact: true }).getAttribute("aria-current")).toBe("true");
+ await page.getByRole("button", { name: "new-handler Endpoint", exact: true }).waitFor();
+ expect(await page.getByRole("button", { name: "new-handler Endpoint", exact: true }).getAttribute("aria-current")).toBe("true");
+ expect(calls.find(call => call.name === "artifact_write")?.arguments).toMatchObject({ name: "new-handler", server: expect.stringContaining("ServerEnv"), slug: "new-handler", access: "private", expected_revision: null });
expect(calls.filter(call => call.name === "script_run")).toHaveLength(1);
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Remix", exact: true }).click();
await page.getByRole("textbox", {name:"Remix name"}).fill("remixed-handler");
await page.getByRole("button", {name:"Create remix",exact:true}).click();
- const remixedRow=page.getByRole("button",{name:"remixed-handler Script",exact:true});
+ const remixedRow=page.getByRole("button",{name:"remixed-handler Endpoint",exact:true});
await remixedRow.waitFor();
expect(await remixedRow.getAttribute("aria-current")).toBe("true");
- expect(calls.at(-1)).toEqual({name:"script_remix",arguments:{name:"new-handler",new_name:"remixed-handler"}});
+ expect(calls.at(-1)).toEqual({name:"artifact_remix",arguments:{name:"new-handler",new_name:"remixed-handler"}});
hosted = false;
await page.reload();
await page.getByRole("tab", { name: "Preview", exact: true }).click();
await page.getByTitle("Preview of local").waitFor();
await page.getByRole("button", { name: "Create or import", exact: true }).click();
- expect(await page.getByRole("menuitem", { name: "New script", exact: true }).count()).toBe(0);
+ expect(await page.getByRole("menuitem", { name: "New endpoint", exact: true }).count()).toBe(0);
expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0);
} finally { await browser.close(); server.stop(true); }
}, 30000);
diff --git a/e2e/gallery-secrets.test.ts b/e2e/gallery-secrets.test.ts
index 483b2f1..be31c14 100644
--- a/e2e/gallery-secrets.test.ts
+++ b/e2e/gallery-secrets.test.ts
@@ -38,7 +38,7 @@ test("gallery exposes server secret names and writes for artifacts and scripts w
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(server.url.href);
- for (const [name, label, kind] of [["app", "app", "artifact"], ["handler", "handler Script", "script"]] as const) {
+ for (const [name, label, kind] of [["app", "app", "artifact"], ["handler", "handler Legacy script", "script"]] as const) {
await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Settings", exact: true }).click();
diff --git a/examples/counter.artifact.server.ts b/examples/counter.artifact.server.ts
index 88bcb1f..2b7d48c 100644
--- a/examples/counter.artifact.server.ts
+++ b/examples/counter.artifact.server.ts
@@ -1,18 +1,16 @@
-import { DurableObject } from "cloudflare:workers";
-
-export class ArtifactServer extends DurableObject {
- fetch(request: Request): Response {
+export default {
+ fetch(request: Request, env: ServerEnv): Response {
if (new URL(request.url).pathname !== "/counter") return new Response("Not found", { status: 404 });
if (request.method !== "GET" && request.method !== "POST") return new Response("Method not allowed", { status: 405 });
- this.ctx.storage.sql.exec("create table if not exists counter (id integer primary key, value integer not null)");
- this.ctx.storage.sql.exec("insert or ignore into counter values (1, 0)");
+ env.sql.exec("create table if not exists counter (id integer primary key, value integer not null)");
+ env.sql.exec("insert or ignore into counter values (1, 0)");
if (request.method === "POST") {
- this.ctx.storage.transactionSync(() => {
- this.ctx.storage.sql.exec("update counter set value = value + 1 where id = 1");
- this.ctx.storage.kv.put("lastUpdated", new Date().toISOString());
+ env.storage.transactionSync(() => {
+ env.sql.exec("update counter set value = value + 1 where id = 1");
+ env.storage.kv.put("lastUpdated", new Date().toISOString());
});
}
- const row = this.ctx.storage.sql.exec<{ value: number }>("select value from counter where id = 1").one();
- return Response.json({ value: row.value, lastUpdated: this.ctx.storage.kv.get("lastUpdated") ?? null });
- }
-}
+ const row = env.sql.exec<{ value: number }>("select value from counter where id = 1").one();
+ return Response.json({ value: row.value, lastUpdated: env.storage.kv.get("lastUpdated") ?? null });
+ },
+} satisfies ExportedHandler;
diff --git a/plugins/artifacts/skills/author/SKILL.md b/plugins/artifacts/skills/author/SKILL.md
index 5fc1875..23455b7 100644
--- a/plugins/artifacts/skills/author/SKILL.md
+++ b/plugins/artifacts/skills/author/SKILL.md
@@ -5,7 +5,7 @@ description: Create or edit React artifacts using the configured Artifacts MCP s
Read `artifact_guide` once in the current conversation before authoring. It owns the SDK exports, allowed imports, runtime restrictions, and validation behavior. Use the connected server's current tool schemas rather than assuming every deployment exposes the same tools.
-The server advertises six direct artifact tools plus `artifacts_discover`, `artifacts_query` and `artifacts_execute`. For history, restore, files, scripts, schedules, secrets, plugins or native Workers, search `artifacts_discover` with `query`, then inspect the chosen `operation` for its exact schema and relevant guide. Call it using `{ operation, arguments }` through the recommended dispatcher. Use `include_docs: false` when its guide is already loaded. All supported operations use the same connection; do not switch configuration. Query enforces reads; execution, including authored HTTP GETs and plugin calls, requires execute. Shared dispatch retains validation, authorization and preview results.
+The server advertises six direct artifact tools plus `artifacts_discover`, `artifacts_query` and `artifacts_execute`. For history, restore, files, servers, logs, schedules, secrets, plugins or native Workers, search `artifacts_discover` with `query`, then inspect the chosen `operation` for its exact schema and relevant guide. Call it using `{ operation, arguments }` through the recommended dispatcher. Use `include_docs: false` when its guide is already loaded. All supported operations use the same connection; do not switch configuration. Query enforces reads; execution, including authored HTTP GETs and plugin calls, requires execute. Shared dispatch retains validation, authorization and preview results.
For an existing artifact, read the relevant source with `artifact_read` before editing. Pass its `source_hash` as `expected_hash` to `artifact_edit`; on conflict, reread and reconcile the user's changes. Use exact replacements with unique matches. A failed validation may leave source applied: inspect the result and repair diagnostics instead of assuming rollback.
diff --git a/skills/artifacts/SKILL.md b/skills/artifacts/SKILL.md
index aff24c7..dd0dcd8 100644
--- a/skills/artifacts/SKILL.md
+++ b/skills/artifacts/SKILL.md
@@ -1,6 +1,6 @@
---
name: artifacts
-description: Create and edit Sidequery artifacts or hosted HTTP scripts, choose root URL slugs, and use storage, secrets and bundled third-party dependencies through MCP; optional local CLI and Herdr integration.
+description: Create and edit Sidequery artifacts, including hosted HTTP endpoints without a UI, choose root URL slugs, and use storage, secrets and bundled third-party dependencies through MCP; optional local CLI and Herdr integration.
---
# Sidequery Artifacts
@@ -11,17 +11,19 @@ tool, or asks for a hosted script, HTTP endpoint, API, HTML handler, webhook, or
## Runtime and artifact choice
-Keep artifacts for React UIs; create standalone scripts for arbitrary Workers-compatible HTTP handlers. Both can have a user/AI-chosen root URL at `/`, with no type prefix or namespace. Discover the available MCP tools first: hosted HTTP MCP exposes `script_*` and `artifact_link`; local Bun stdio and filesystem commands remain artifact-only.
+An artifact has a React UI, a server, or both. Use a UI for anything visual; use a server with no UI for an HTTP endpoint, API, HTML handler, webhook, or scheduled job. Any artifact can have a user/AI-chosen root URL at `/`, with no type prefix or namespace. Hosted HTTP MCP runs servers; local Bun stdio and filesystem commands are UI-only.
-Read `artifact_guide` before writing an artifact and `script_guide` before writing a script. These tools provide the current runtime contract without needing a repository checkout. For hosted scripts, URLs, access, persistence, diagnostics and third-party dependencies, read [docs/scripts.md](../../docs/scripts.md). For dependencies specifically, follow its **Third-party dependencies** recipe: install pinned Workers-compatible packages locally, typecheck original source, prebundle into one module, and send the generated contents to `script_write`. The hosted compiler does not install packages. Keep original source and lockfiles for subsequent edits.
+Read `artifact_guide` before writing an artifact, with topic `backend` for servers. It provides the current runtime contract without needing a repository checkout. For hosted endpoints, URLs, access, persistence, diagnostics and third-party dependencies, read [docs/scripts.md](../../docs/scripts.md). Pass exact dependency versions in `project.dependencies`; the service resolves and archives them. Keep original source for subsequent edits.
-## Hosted scripts and URLs
+## Hosted endpoints and URLs
-Use `script_write({name,slug,contents,access})` with a standard default-exported Workers handler. `env.secrets` contains explicitly configured per-script values, `env.sql` provides persistent SQLite, and outbound `fetch` is available. The MCP guide has the complete supported shape and limits.
+Use `artifact_write({name,slug,access,server})` without `contents`, where `server` is a standard default-exported Workers handler typed `ExportedHandler`. `env.secrets` contains explicitly configured values, `env.sql` provides persistent SQLite, `env.storage` provides KV and transactions, and outbound `fetch` is available. The URL receives paths relative to the slug. The MCP guide has the complete supported shape and limits.
-Use `script_read` and `script_edit` for source changes; check `applied`, `ok` and diagnostics. Invalid drafts retain the working endpoint and chosen slug. Use `script_secrets` rather than embedding credentials, `script_history`/`script_version` for source history, and `script_restore` when restoration is requested.
+Use `artifact_read` and `artifact_edit` for source changes; they select the server when there is no UI. Check `applied`, `ok` and diagnostics. Invalid drafts retain the working endpoint and chosen slug. Use `artifact_secrets` rather than embedding credentials, `artifact_history`/`artifact_version` for source history, and `artifact_restore` when restoration is requested.
-Use `artifact_link` for either artifact kind, or `slug` on creation. Private is the default; use public only when external access is intended. Return the exact URL supplied by the tool. Explicit `script_run` and visiting a script URL execute user code; reading, listing and editing do not invoke its handler. Validate intended requests and inspect response status/body and `script_logs` before claiming it works.
+Use `artifact_link`, or `slug` on creation. Private is the default; use public only when external access is intended. Return the exact URL supplied by the tool. Explicit `artifact_request` and visiting the URL execute user code; reading, listing and editing do not invoke its handler. Validate intended requests and inspect response status/body and `artifact_logs` before claiming it works.
+
+Scripts created with the older `script_*` tools are listed as `legacy_scripts`; the same `artifact_*` tools operate on them by name, and `artifact_remix` copies one into a server-only artifact.
## Local artifact location
diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx
index 84fe29f..6d8e919 100644
--- a/src/gallery/client.tsx
+++ b/src/gallery/client.tsx
@@ -1,11 +1,11 @@
import { Tabs } from "@base-ui/react/tabs";
import { Menu } from "@base-ui/react/menu";
-import { LibraryNavigation } from "./library-navigation";
+import { LibraryNavigation, matchesKindFilter } from "./library-navigation";
import { DeletePanel, TrashPanel } from "./trash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { authClient, signInUrl } from "../auth/client-api";
-import type { GalleryArtifact, GalleryData, GalleryWorker } from "./types";
+import { isServerOnly, type GalleryArtifact, type GalleryData, type GalleryWorker } from "./types";
import { ExecutionControls } from "./execution-controls";
import { SecretControls } from "./secrets";
import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted";
@@ -106,7 +106,7 @@ function App() {
try { localStorage.setItem("artifacts-theme", theme); } catch { /* The theme still works when storage is unavailable. */ }
}, [theme]);
const [remixing, setRemixing] = useState(false);
- const [creatingKind, setCreatingKind] = useState<"artifact" | "script" | null>(null);
+ const [creatingKind, setCreatingKind] = useState<"artifact" | "endpoint" | null>(null);
const [selectedWorker, setSelectedWorker] = useState(workerSelection);
const showNativeApps = selectedWorker !== null;
const workerEditor = useRef(null);
@@ -263,7 +263,7 @@ function App() {
const filteredArtifacts = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
return libraryItems.filter((artifact) =>
- (kindFilter === "all" || (artifact.kind ?? "artifact") === kindFilter) &&
+ matchesKindFilter(artifact, kindFilter) &&
(!normalized || `${artifact.name}\n${artifact.workspace}`.toLocaleLowerCase().includes(normalized)),
);
}, [libraryItems, query, kindFilter]);
@@ -276,7 +276,7 @@ function App() {
? gallery?.workerApps?.find(worker => worker.workspace === selectedWorker.workspace && worker.name === selectedWorker.name)?.key ?? null
: null;
- const activeTab: DetailTab = selectedArtifact?.kind === "script"
+ const activeTab: DetailTab = isServerOnly(selectedArtifact)
? tab === "preview" ? "source" : tab
: tab === "requests" || ((tab === "settings" || tab === "schedule") && !gallery?.capabilities?.links) ? "preview" : tab;
@@ -303,7 +303,7 @@ function App() {
if (activeTab === "source" && selectedArtifact && resolvedVersion) setSourceVisited(`${selectedArtifact.key}:${resolvedVersion}`);
}, [activeTab, selectedArtifact?.key, resolvedVersion]);
- const previewUrl = selectedArtifact && selectedArtifact.kind !== "script" && resolvedVersion
+ const previewUrl = selectedArtifact && !isServerOnly(selectedArtifact) && resolvedVersion
? `${artifactUrl("/gallery/preview", selectedArtifact, resolvedVersion)}&revision=${encodeURIComponent(resolvedVersion === WORKING_VERSION ? selectedArtifact.draftRevision ?? selectedArtifact.liveId ?? "" : resolvedVersion)}`
: "";
const downloadUrl = selectedArtifact && resolvedVersion
@@ -442,7 +442,7 @@ function App() {
};
function workerCanLeave() { return workerEditor.current?.canLeave() ?? true; }
- function startCreating(kind: "artifact" | "script") {
+ function startCreating(kind: "artifact" | "endpoint") {
if (!leaveWorker()) return;
setCreatingKind(kind);
setTab("source");
@@ -454,8 +454,9 @@ function App() {
setShowDelete(false);
setMobileDetail(true);
}
- async function createdItem(name: string, kind: "artifact" | "script") {
- createdRemix.current = { name, kind, workspace: gallery?.workspace ?? "default" };
+ async function createdItem(name: string, kind: "artifact" | "endpoint") {
+ // Endpoints are server-only artifacts.
+ createdRemix.current = { name, kind: "artifact", workspace: gallery?.workspace ?? "default" };
await loadGallery();
setCreatingKind(null);
setSelectedVersion("working");
@@ -533,7 +534,7 @@ function App() {
const url = URL.createObjectURL(await response.blob());
const anchor = document.createElement("a");
anchor.href = url;
- anchor.download = new URL(downloadHref).searchParams.get("format") === "project" ? `${selectedArtifact?.name ?? "artifact"}.artifact-project.json` : `${selectedArtifact?.name ?? "artifact"}${selectedArtifact?.kind === "script" ? ".ts" : ".artifact.tsx"}`;
+ anchor.download = new URL(downloadHref).searchParams.get("format") === "project" ? `${selectedArtifact?.name ?? "artifact"}.artifact-project.json` : `${selectedArtifact?.name ?? "artifact"}${selectedArtifact?.kind === "script" ? ".ts" : selectedArtifact?.serverOnly ? ".artifact.server.ts" : ".artifact.tsx"}`;
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
@@ -552,8 +553,8 @@ function App() {
+ startCreating("artifact")}>New artifact
- {gallery?.capabilities?.scripts ? startCreating("script")}>New script : null}
- {gallery?.capabilities?.nativeApps ? selectWorker({ workspace: gallery.workspace, name: null })}>New Worker app : null}
+ {gallery?.capabilities?.scripts ? startCreating("endpoint")}>New endpoint : null}
+ {gallery?.capabilities?.nativeApps ? selectWorker({ workspace: gallery.workspace, name: null })}>New native Worker : null}
{ if (!leaveWorker()) return; setShowImport(true); setCreatingKind(null); setShowLinks(false); setShowMove(false); setRemixing(false); setNotice(""); setMobileDetail(true); }}>Import project
@@ -604,9 +605,9 @@ function App() {
: null}
{viewingItem ? <>
-
+
{([
- ...(selectedArtifact.kind === "script" ? [["source", "Source"], ["requests", "Requests"]] : [["preview", "Preview"], ["source", "Source"]]),
+ ...(isServerOnly(selectedArtifact) ? [["source", "Source"], ["requests", "Requests"]] : [["preview", "Preview"], ["source", "Source"]]),
// Schedule and Settings open from More actions; while one is open it shows here so the header says where you are.
...(activeTab === "schedule" ? [["schedule", "Schedule"]] : activeTab === "settings" ? [["settings", "Settings"]] : []),
] as [DetailTab, string][]).map(([value, label]) => {label})}
@@ -614,7 +615,7 @@ function App() {
> : null}
{viewingItem ?
: null}
@@ -256,7 +259,7 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS
) : null}
{status ?
{status}
: null}
- {appName && !selected && busy ?
Loading Worker app…
: null}
+ {appName && !selected && busy ?
Loading native Worker…
: null}
{selected && !providerAvailable ? (
{selected.name} uses {selected.provider}, which is not available on this deployment. Configure that provider
@@ -265,9 +268,9 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS
) : null}
{!providers.length ? (
-
Worker apps are unavailable
+
Native Workers are unavailable
- No Worker app provider is available on this deployment. Configure a provider to create and deploy Workers.
+ No native Worker provider is available on this deployment. Configure a provider to create and deploy Workers.
diff --git a/src/mcp/discovery.test.ts b/src/mcp/discovery.test.ts
index e609c54..2897158 100644
--- a/src/mcp/discovery.test.ts
+++ b/src/mcp/discovery.test.ts
@@ -15,12 +15,12 @@ test("every hosted operation can be inspected through the stable nine-tool surfa
"artifacts_discover", "artifacts_execute", "artifacts_query",
]);
const catalog = operationCatalog(CLOUD_MCP_TOOLS);
- expect(catalog).toHaveLength(53);
+ expect(catalog).toHaveLength(54);
const noExecution = async () => { throw new Error("Discovery without docs must not execute anything"); };
const listing = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { limit: 50 }, noExecution);
- expect(listing.structuredContent).toMatchObject({ total: 53, next_offset: 50 });
+ expect(listing.structuredContent).toMatchObject({ total: 54, next_offset: 50 });
const lastPage = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { offset: 50, limit: 50 }, noExecution);
- expect(lastPage.structuredContent).toMatchObject({ total: 53, next_offset: null });
+ expect(lastPage.structuredContent).toMatchObject({ total: 54, next_offset: null });
for (const operation of catalog) {
const result = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { operation: operation.name, include_docs: false }, noExecution);
expect(result.structuredContent).toMatchObject({ operation: operation.name, inputSchema: operation.inputSchema });
diff --git a/src/mcp/discovery.ts b/src/mcp/discovery.ts
index 847c1b3..9434542 100644
--- a/src/mcp/discovery.ts
+++ b/src/mcp/discovery.ts
@@ -79,7 +79,7 @@ export function compactToolSurface(tools: readonly Tool[]): Tool[] {
// An explicit allowlist is intentional: newly added operations default to actions.
// An HTTP GET to authored code is still execution, irrespective of its method.
const READ_OPERATIONS = new Set([
- "artifact_guide", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", "artifact_typecheck", "artifact_runs",
+ "artifact_guide", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", "artifact_typecheck", "artifact_runs", "artifact_logs",
"script_guide", "script_list", "script_read", "script_history", "script_version", "script_export", "script_logs", "script_runs",
"app_guide", "app_list", "app_read", "app_history", "plugins_list", "plugin_guide",
"artifacts_trash",
@@ -165,7 +165,7 @@ export async function callDiscoveryTool(
const guide = guideOperation(tool.name);
let guideArgs: Record = {};
if (tool.name === "artifact_files") guideArgs = { topic: "files" };
- else if (["artifact_request", "artifact_schedule", "artifact_runs", "artifact_secrets"].includes(tool.name)) guideArgs = { topic: "backend" };
+ else if (["artifact_request", "artifact_schedule", "artifact_runs", "artifact_logs", "artifact_secrets"].includes(tool.name)) guideArgs = { topic: "backend" };
const documentation = args.include_docs === false ? undefined : await callOperation(guide, guideArgs);
return jsonResult({
...operationSummary(tool), inputSchema: tool.inputSchema,
diff --git a/src/mcp/gallery-contract.ts b/src/mcp/gallery-contract.ts
index ff17c5d..13e828d 100644
--- a/src/mcp/gallery-contract.ts
+++ b/src/mcp/gallery-contract.ts
@@ -5,7 +5,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
export const GALLERY_TOOL_NAMES = [
"artifact_write", "artifact_restore", "artifact_remix", "artifact_import", "artifact_export", "artifact_link",
"script_write", "script_restore", "script_remix", "script_import", "script_export", "script_run", "script_logs",
- "script_schedule", "script_runs", "artifact_schedule", "artifact_runs", "artifact_secrets", "script_secrets",
+ "script_schedule", "script_runs", "artifact_schedule", "artifact_runs", "artifact_logs", "artifact_request", "artifact_secrets", "script_secrets",
"artifact_delete", "artifact_undelete", "script_delete", "script_undelete", "artifacts_trash",
] as const;
diff --git a/src/mcp/guide.ts b/src/mcp/guide.ts
index 652ad1a..6a8e0ee 100644
--- a/src/mcp/guide.ts
+++ b/src/mcp/guide.ts
@@ -49,21 +49,23 @@ The runtime supplies React Router. Use Routes, Route, Outlet, Navigate, NavLink
artifactFetch(path: string, init?: RequestInit): Promise sends a request to the artifact's native server through the host bridge. Use relative paths and standard methods, headers, and bodies; check response.ok before reading response.json() or response.text(). Cross-origin and protocol-relative URLs are rejected. Request and response bodies are limited to 256 KiB.
-Server requests require a hosted runtime with a native artifacts server. Local Bun CLI, local stdio MCP and local gallery views do not execute servers and report requests as unavailable. Hosted MCP Apps and the hosted gallery support them. Hosted artifact_write accepts server TypeScript exporting class ArtifactServer extends DurableObject from "cloudflare:workers", alongside browser contents.
+Server requests require a hosted runtime with a native artifacts server. Local Bun CLI, local stdio MCP and local gallery views do not execute servers and report requests as unavailable. Hosted MCP Apps and the hosted gallery support them. Hosted artifact_write accepts server TypeScript alongside or instead of browser contents. Prefer a Workers module: export default { async fetch(request, env, ctx) { ... } } satisfies ExportedHandler. env.sql is the artifact's SQLite database, env.storage provides kv and transactionSync, env.secrets holds its secrets, and ctx.waitUntil extends background work. A class ArtifactServer extends DurableObject from "cloudflare:workers" is also accepted and uses this.ctx.storage and this.env.secrets instead.
+
+An artifact written without contents has no UI. It is an HTTP endpoint, webhook or scheduled job: its whole URL runs the server, and artifact_read/artifact_edit select its server by default. Omitting contents preserves the saved UI; contents null removes it. Scripts saved with script_write before server-only artifacts existed are legacy scripts: artifact_list returns them as legacy_scripts, and artifact_read, artifact_edit, artifact_write (server only), artifact_history, artifact_version, artifact_restore, artifact_export, artifact_request, artifact_schedule, artifact_runs, artifact_logs and artifact_secrets operate on them by name. script_* tools keep working. artifact_remix copies a legacy script into a new server-only artifact with fresh storage, secrets and schedule; its handler then receives paths relative to the artifact URL instead of the full deployment path.
getArtifactCapabilities() returns {server, files, plugins, hostActions, actions: {openUrl, promptAgent, openFile}, modelContext, statePersistence} for the current view without making requests. The booleans describe installed bridges; individual operations still follow the current access policy. statePersistence is "persistent" when UI state is saved through the host, otherwise "session". Gallery and MCP UI state is temporary; use server SQLite for durable app data. Query capabilities before showing controls that require server, file, plugin or host-action support.
-Each artifact with a server gets one native SQLite database. Different artifact names get separate databases; multiple tabs and server restarts use the same database. Use this.ctx.storage.sql.exec(sql, ...bindings) with ? placeholders for values; cursors support .toArray() and .one(). Use this.ctx.storage.kv.get/put/delete for key/value data and this.ctx.storage.transactionSync(() => { ... }) for synchronous SQL/KV transactions. Initialize tables with create table if not exists.
+Each artifact with a server gets one native SQLite database. Different artifact names get separate databases; multiple tabs and server restarts use the same database. Use env.sql.exec(sql, ...bindings) with ? placeholders for values; cursors support .toArray() and .one(). Use env.storage.kv.get/put/delete for key/value data and env.storage.transactionSync(() => { ... }) for synchronous SQL/KV transactions (this.ctx.storage in the class form). Initialize tables with create table if not exists.
-Artifacts has no application-schema migration runner. For schema changes, keep a schema version in the database and apply pending changes plus the version update in one this.ctx.storage.transactionSync during server initialization, before serving requests. Saving source does not execute migrations; they run when that server is next requested. Prefer additive changes compatible with older source because source restore does not reverse migrations.
+Artifacts has no application-schema migration runner. For schema changes, keep a schema version in the database and apply pending changes plus the version update in one env.storage.transactionSync during server initialization, before serving requests. Saving source does not execute migrations; they run when that server is next requested. Prefer additive changes compatible with older source because source restore does not reverse migrations.
Omitting server preserves existing code; null removes code without deleting the database. Hosted artifact_read/artifact_edit accept part: "server". Browser and server source are versioned together; the database remains live across source edits and restores. Archived code also runs against the current database, not a historical snapshot.
-Database identity is library + workspace + artifact name: private libraries isolate users; an authorized team library shares data. Server code supports outbound fetch and Workers-compatible node: imports. Extend DurableObject to access this.env.secrets. Use artifact_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) or the gallery Secrets tab to set/delete values; omit secrets to list names. Limits match scripts: 32 keys, 4 KiB per value, 32 KiB total. Values stay out of source history, exports and management reads; never return them in app responses. Updates reload the server while retaining SQL/KV. Remixes and imports start with no secrets; source restores retain current secrets. No ordinary D1, R2 or custom Worker bindings are supplied. Cloudflare applies the same 30 seconds CPU and 50 subrequests as scripts; celld does not enforce these budgets.
+Database identity is library + workspace + artifact name: private libraries isolate users; an authorized team library shares data. Server code supports outbound fetch and Workers-compatible node: imports. Module handlers read env.secrets; class servers extend DurableObject to access this.env.secrets. Use artifact_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) or the gallery Secrets tab to set/delete values; omit secrets to list names. Limits match scripts: 32 keys, 4 KiB per value, 32 KiB total. Values stay out of source history, exports and management reads; never return them in app responses. Updates reload the server while retaining SQL/KV. Remixes and imports start with no secrets; source restores retain current secrets. No ordinary D1, R2 or custom Worker bindings are supplied. Cloudflare applies the same 30 seconds CPU and 50 subrequests as scripts; celld does not enforce these budgets.
-Direct HTTP requests to /slug/api and /slug/api/* reach the active artifact backend with the deployment origin, /api path and query preserved. Direct requests/responses stream without the browser/MCP 256 KiB envelope limit, subject to runtime and proxy limits. Browser artifactFetch and MCP artifact_request remain buffered at 256 KiB. Unknown API routes never fall back to page HTML; an artifact without a backend returns JSON 404. Management cookies and Access credentials are stripped; private routes also strip management Authorization. Public application Authorization is preserved. Responses cannot set cookies, and HTML remains sandboxed.
+Direct HTTP requests to /slug/api and /slug/api/* reach the active artifact backend with the deployment origin, /api path and query preserved. Direct requests/responses stream without the browser/MCP 256 KiB envelope limit, subject to runtime and proxy limits. Browser artifactFetch and MCP artifact_request remain buffered at 256 KiB. Unknown API routes never fall back to page HTML; an artifact without a backend returns JSON 404. An artifact without a UI receives every path under /slug except /slug/_artifact/*, relative to the slug: /slug/hooks/github arrives as /hooks/github. Public server-only artifacts accept requests from other origins like any public API; UI artifacts reject cross-origin /api requests. Management cookies and Access credentials are stripped; private routes also strip management Authorization. Public application Authorization is preserved. Responses cannot set cookies, and HTML remains sandboxed.
-Implement fetch(request: Request) on ArtifactServer and return a Response; browser code calls it with artifactFetch. SQLite stores durable application data; useState and useArtifactState hold UI state. Agents can also invoke the hosted artifact_request tool with name or version_id and a request envelope (path, method, headers, base64 body).
+The server's fetch handler returns a Response; browser code calls it with artifactFetch. SQLite stores durable application data; useState and useArtifactState hold UI state. Agents can also invoke the hosted artifact_request tool with name or version_id and a request envelope (path relative to the artifact URL, method, headers, base64 body). artifact_logs reads the latest 100 console lines, request summaries and errors, with secret values redacted; filter by a run_id from artifact_runs.
## Persistent files
@@ -82,7 +84,7 @@ Files use the same library + workspace + artifact identity as the database and s
## Restrictions and validation
-For browser TSX, embed data in the source or use artifactFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to native server source, which imports DurableObject from "cloudflare:workers".
+For browser TSX, embed data in the source or use artifactFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to server source, which may import Workers cloudflare: and node: builtins.
artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. Use preview:false on write/edit/restore/import/remix for intermediate changes; validation and revision capture still run without delivering another view. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. artifact_export returns a complete version 1 project archive including helpers and exact package source/type snapshots, with no runtime data or secrets. artifact_import accepts that archive and a fresh new_name; hosted imports start private with new storage. Filesystem imports preserve backend source for deployment but do not execute it.
`;