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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 24 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions cloudflare/artifact-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ArtifactLog>("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 => {
Expand Down
42 changes: 28 additions & 14 deletions cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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 });
Expand Down Expand Up @@ -44,12 +51,19 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
if (link.access === "private") {
const identity = await identify(request, env);
if (identity instanceof Response) return identity;
const owner = await env.LINKS.getByName("deployment").owner(link);
const owner = await linkOwner(link, env);
if (owner !== "team" && owner !== identity.privateKey) return new Response("Not found", { status: 404 });
plugins = { user: identity.user, env };
const origin = request.headers.get("Origin");
if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 });
}
if (link.kind === "app") {
if (!env.NATIVE_APPS) return new Response("Not found", { status: 404 });
// The controller strips credentials and forwards app-relative paths to the native Worker.
const input = artifactApiRequest(request, `/${slug}`, link.access === "private");
input.headers.set("x-artifacts-app-selection", JSON.stringify({ id: link.name }));
return env.NATIVE_APPS.getByName("deployment").fetch(input);
}
if (link.kind === "script") {
if (!link.script_hash) return new Response("Script has no valid version", { status: 409 });
// Management session credentials never become inputs to user-authored code.
Expand Down Expand Up @@ -88,18 +102,6 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
const result = await service.fileRequest({ name: link.name, version_id: link.version_id }, args.request, link.access === "private");
return Response.json({ result }, { headers: { "cache-control": "private, no-store" } });
}
if (url.pathname === `/${slug}/api` || url.pathname.startsWith(`/${slug}/api/`)) {
const origin = request.headers.get("Origin");
if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 });
const snapshot = await service.snapshot({ version_id: link.version_id });
if (snapshot.server_source === null) return Response.json({ error: "Artifact has no server" }, { status: 404 });
if (!snapshot.compiled_id) return Response.json({ error: "Artifact has no saved server bundle" }, { status: 409 });
const input = artifactApiRequest(request, `/${slug}`, link.access === "private");
input.headers.set(ARTIFACT_HEADER, JSON.stringify({ libraryKey: link.libraryKey, workspace: link.workspace, name: link.name,
compiled_id: snapshot.compiled_id, version_id: link.version_id, original: input.headers.get(ARTIFACT_HEADER) }));
const backend = env.BACKENDS.getByName(JSON.stringify([link.libraryKey, link.workspace, link.name]));
return artifactApiResponse(await backend.fetch(input), request.method);
}
if (url.pathname === `/${slug}/_artifact/plugins`) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405, headers: { Allow: "POST" } });
let input: PluginRequest;
Expand All @@ -123,8 +125,20 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
return Response.json({ response: await service.request({ name: link.name, version_id: link.version_id }, args.request) }, { headers: { "cache-control": "no-store" } });
}
if (url.pathname === `/${slug}/_artifact` || url.pathname.startsWith(`/${slug}/_artifact/`)) return new Response("Not found", { status: 404 });
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
const snapshot = await service.snapshot({ version_id: link.version_id });
// A server-only artifact's whole URL is its HTTP API; UI artifacts serve /api/*.
if (snapshot.source === "" || url.pathname === `/${slug}/api` || url.pathname.startsWith(`/${slug}/api/`)) {
const origin = request.headers.get("Origin");
if (snapshot.source !== "" && origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 });
if (snapshot.server_source === null) return Response.json({ error: "Artifact has no server" }, { status: 404 });
if (!snapshot.compiled_id) return Response.json({ error: "Artifact has no saved server bundle" }, { status: 409 });
const input = artifactApiRequest(request, `/${slug}`, link.access === "private");
input.headers.set(ARTIFACT_HEADER, JSON.stringify({ libraryKey: link.libraryKey, workspace: link.workspace, name: link.name,
compiled_id: snapshot.compiled_id, version_id: link.version_id, original: input.headers.get(ARTIFACT_HEADER) }));
const backend = env.BACKENDS.getByName(JSON.stringify([link.libraryKey, link.workspace, link.name]));
return artifactApiResponse(await backend.fetch(input), request.method);
}
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
const preview = await service.preview(snapshot);
if (!preview.ok || !preview._meta) return new Response(preview.check, { status: 400 });
const payload = preview._meta.artifact;
Expand Down
4 changes: 2 additions & 2 deletions cloudflare/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,9 @@ test("ownership transfer preserves live resources and revokes former library acc
const decode = (result: any) => 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<ArtifactEnv> {")
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}); }}';
Expand Down
Loading
Loading