Guestbook on durable state
app.unfazed.dev/guestbook open (opens in new tab)make a guestbook page for my personal site. entries have a name and a short message and must survive restarts and redeploys — but i don't want to set up a database. deploy it on unfazed as an always-on service and give me the link.
I'll build the guestbook (storing entries in Unfazed's durable per-app state, so no database needed) and deploy it as an always-on service. Loading the design skill and deployment tools first.
Skill(skill: "frontend-design:frontend-design", …)
{
"skill": "frontend-design:frontend-design",
"args": "guestbook page for a personal site"
}Launching skill: frontend-design:frontend-design Base directory for this skill: /.claude/plugins/cache/claude-code-plugins/frontend-design/1.0.0/skills/frontend-design This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. ## Design Thinking Before coding, understand the context and commit to a BOLD aesthetic direction: - **Purpose**: What problem does this interface solve? Who uses it? - **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. - **Constraints**: Technical requirements (framework, performance, accessibility). - **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? **CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: - Production-grade and functional - Visually striking and memorable - Cohesive with a clear aesthetic point-of-view - Meticulously refined in every detail ## Frontend Aesthetics Guidelines Focus on: - **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. - **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. - **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. - **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. - **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. **IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. ARGUMENTS: guestbook page for a personal site
ToolSearch(query: "select:mcp__unfazed__create_workspace,mc…", max_results: 5)
{
"query": "select:mcp__unfazed__create_workspace,mcp__unfazed__write_workspace_text_file,mcp__unfazed__create_service_deployment,mcp__unfazed__get_service_status,ReadMcpResourceTool",
"max_results": 5
}[
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__create_workspace"
},
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__write_workspace_text_file"
},
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__create_service_deployment"
},
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__get_service_status"
},
{
"type": "tool_reference",
"tool_name": "ReadMcpResourceTool"
}
]ReadMcpResourceTool(server: "unfazed", uri: "unfazed://llms.txt")
{
"server": "unfazed",
"uri": "unfazed://llms.txt"
}{
"contents": [
{
"uri": "unfazed://llms.txt",
"mimeType": "text/plain; charset=utf-8",
"text": "# Unfazed\n\nUnfazed is a self-hosted agent deployment sandbox at https://unfazed.dev.\nIt is intended for small local/agent workflows, not high-scale production\nhosting.\n\n## API\n\nPreferred agent endpoint:\n\nhttps://unfazed.dev/mcp\n\nRemote MCP uses Streamable HTTP with OAuth bearer tokens.\nIf security policy or technical limitations do not allow adding the MCP server,\nuse the GraphQL fallback at https://unfazed.dev/query with the same bearer\ntoken. GraphQL introspection is available.\n\nAuth discovery:\n\n- https://unfazed.dev/auth.md\n- POST https://unfazed.dev/agents/register for self-service agent tokens\n- Agent registration may return claim.url. Claiming is optional and only for\n human management of limits, domains, billing, revocation, or handoff.\n- If the initial claim URL/code is lost, call MCP tool create_claim_attempt or\n POST https://unfazed.dev/agents/claim-attempts with the agent bearer token.\n\nOAuth discovery:\n\n- https://unfazed.dev/.well-known/oauth-protected-resource/mcp\n- https://unfazed.dev/.well-known/oauth-authorization-server\n- https://unfazed.dev/.well-known/openid-configuration\n\nMCP tools:\n\n- get_service_info\n- whoami\n- create_claim_attempt\n- create_workspace\n- write_workspace_text_file\n- write_workspace_base64_file\n- read_workspace_file\n- list_workspace_files\n- list_workspace_env\n- set_workspace_env\n- set_workspace_secret\n- delete_workspace_env\n- list_deployments\n- list_deployment_events\n- create_static_deployment\n- create_service_deployment\n- get_service_status\n- get_service_logs\n- restart_service\n- stop_service\n\nMCP list/read/log tools are context-safe by default.\n\n- Call whoami first when identity matters. It returns the current actor kind\n (owner, user, or agent), nested user/agent identity, linked agents for a\n user, scopes, claim state, and free-tier usage/resource counters when\n available.\n- list_deployments, list_deployment_events, list_workspace_files, and\n list_workspace_env return pages with total, limit, offset, has_more, and\n next_offset. Continue with offset: next_offset while has_more is true.\n- list_deployments, list_deployment_events, and list_workspace_env default to\n format: \"compact\". Use format: \"full\" only when detailed fields, event\n metadata, or plaintext non-secret env values are needed.\n- read_workspace_file returns a text chunk. Continue with offset: next_offset\n while truncated is true.\n- get_service_logs returns recent stdout/stderr tails. Pass tail_bytes only when\n a larger tail is needed; it is capped at 64 KiB per stream.\n- write_workspace_text_file writes UTF-8 content.\n- write_workspace_base64_file writes bytes read from a local file or binary\n asset. If your client supports GraphQL multipart upload and that is more\n convenient, use uploadWorkspaceFile at https://unfazed.dev/query instead.\n\nTypical MCP static deploy flow:\n\n- create_workspace({ name })\n- write_workspace_text_file({ workspace_id, path, text })\n- write_workspace_base64_file({ workspace_id, path, content_base64 }) for local\n files or binary assets\n- create_static_deployment({ workspace_id, alias, spa_mode: true })\n- share url from the deployment result\n\nTypical MCP service deploy flow:\n\n- create_workspace({ name })\n- write_workspace_text_file({ workspace_id, path: \"package.json\", text })\n- write_workspace_text_file({ workspace_id, path: \"server.ts\", text })\n- write_workspace_text_file again for each additional text source file\n- optional: set_workspace_env({ workspace_id, name, value }) for non-secret config\n- optional: set_workspace_secret({ workspace_id, name, value }) for credentials\n- create_service_deployment({ workspace_id, alias })\n- call url from the deployment result\n- optional: list_deployment_events({ deployment_id, limit: 20, offset: 0 })\n- optional: get_service_status({ deployment_id })\n- optional: get_service_logs({ deployment_id, tail_bytes: 16384 })\n- optional: restart_service({ deployment_id }) or stop_service({ deployment_id })\n\nSecrets are encrypted at rest and never returned by API tools. Use\nlist_workspace_env to see names; secret values are redacted. Env/secrets are\nsnapshotted when a deployment is created, so changing workspace env later does\nnot mutate older deployments.\n\nFor a long-lived supervised service, pass:\n\n- create_service_deployment({ workspace_id, alias, service_mode: \"always_on\", health_path: \"/health\" })\n\nThe always-on watchdog is platform-managed. User code does not need to ping koo.\nIf health_path is set, koo checks that HTTP path from the host and restarts the\nFirecracker VM when it fails. If health_path is omitted, koo checks TCP reachability.\n\nMinimal server.ts:\n\nimport { Hono } from \"hono\";\n\nconst app = new Hono();\napp.get(\"/\", (c) =\u003e c.text(\"hello\"));\n\nexport default app;\n\nCompatibility GraphQL endpoint:\n\nhttps://unfazed.dev/query\n\nAuthentication for /query:\n\n- Authorization: Bearer \u003cKOO_API_TOKEN\u003e\n- or X-Koo-Token: \u003cKOO_API_TOKEN\u003e\n- or Authorization: Bearer \u003cagent_access_token\u003e from /agents/register\n\nUse GraphQL when MCP cannot be added as a trusted server. Do not hand-roll MCP\nJSON-RPC with curl if an agent harness blocks it; use /query instead.\n\nPublished deployments are public and do not require a token.\n\n## Supported Workflows\n\n1. Static deployments\n\nUse this when an agent needs to publish HTML reports, small sites, CSS,\nJavaScript, images, or other assets. Files can be added incrementally.\n\nFlow:\n\n- createWorkspace(input: { name })\n- writeWorkspaceTextFile(input: { workspaceID, path, text })\n- writeWorkspaceBase64File(input: { workspaceID, path, contentBase64 }) for binary or\n local-file bytes\n- uploadWorkspaceFile(input: { workspaceID, path, file }) when the GraphQL\n client supports multipart upload\n- writeWorkspaceTextFile or writeWorkspaceBase64File again for each additional\n file\n- createDeployment(input: { workspaceID, kind: STATIC, alias, spaMode })\n- share deployment.url\n\nStable public URL:\n\nhttps://app.unfazed.dev/\u003calias\u003e/\n\nImmutable public URL:\n\nhttps://unfazed.dev/d/\u003cdeployment-id\u003e/\n\nDeploying again with the same alias moves /a/\u003calias\u003e/ to the newest snapshot.\nOlder /d/\u003cdeployment-id\u003e/ URLs remain immutable.\n\n2. Service deployments\n\nUse this when an agent needs to publish a small HTTP backend or combined app.\nThe workspace must contain server.ts. If it has dependencies, include\npackage.json. Build and runtime happen inside Firecracker VMs with Bun.\n\nFlow:\n\n- createWorkspace(input: { name })\n- writeWorkspaceTextFile(input: { workspaceID, path: \"package.json\", text })\n- writeWorkspaceTextFile(input: { workspaceID, path: \"server.ts\", text })\n- optional: setWorkspaceEnv(input: { workspaceID, name, value })\n- optional: setWorkspaceSecret(input: { workspaceID, name, value })\n- createDeployment(input: { workspaceID, kind: SERVICE, alias })\n- call deployment.url\n\nFor always-on:\n\n- createDeployment(input: { workspaceID, kind: SERVICE, alias, serviceMode: ALWAYS_ON, healthPath: \"/health\" })\n\nserver.ts must default-export one of:\n\n- a fetch(req) function\n- a Hono app\n- an object with fetch\n\nPublic URL:\n\nhttps://app.unfazed.dev/\u003calias\u003e/\n\nRuntime control:\n\n- deploymentEvents(deploymentID, limit) / list_deployment_events returns recent\n persistent deployment and runtime events such as deployment.created,\n service.build_succeeded, service.vm_started, service.vm_stopped,\n service.watchdog_restart, service.restart_requested, and service.stop_requested.\n- serviceStatus(deploymentID) returns live VM health, VM id, in-flight count,\n restart count, timestamps, and last runtime error.\n- serviceLogs(deploymentID, tailBytes) returns bounded stdout/stderr tails.\n Secret values configured with setWorkspaceSecret are redacted.\n- restartService(input: { deploymentID }) restarts the service VM and returns\n status.\n- stopService(input: { deploymentID }) stops the current service VM and\n always-on supervisor.\n\n3. TypeScript lambdas\n\nregisterFunction(input: { name, file, packageJSON, bunLock }) uploads a TypeScript module.\ninvokeFunction(input: { functionID, payload }) runs it inside a Firecracker VM.\n\nA lambda module must export a default function.\n\n## Minimal Static GraphQL\n\nmutation Create($name: String!) {\n createWorkspace(input: { name: $name }) {\n workspace { id name }\n }\n}\n\nmutation Write($workspaceID: ID!, $path: String!, $text: String!) {\n writeWorkspaceTextFile(input: { workspaceID: $workspaceID, path: $path, text: $text }) {\n file { path sizeBytes sha256 updatedAt }\n }\n}\n\nmutation WriteBase64($workspaceID: ID!, $path: String!, $contentBase64: String!) {\n writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $path, contentBase64: $contentBase64 }) {\n file { path sizeBytes sha256 updatedAt }\n }\n}\n\nmutation Upload($workspaceID: ID!, $path: String!, $file: File!) {\n uploadWorkspaceFile(input: { workspaceID: $workspaceID, path: $path, file: $file }) {\n file { path sizeBytes sha256 updatedAt }\n }\n}\n\nmutation Deploy($workspaceID: ID!, $alias: String!) {\n createDeployment(input: { workspaceID: $workspaceID, kind: STATIC, alias: $alias, spaMode: true }) {\n deployment { id url alias status filesCount sizeBytes spaMode createdAt }\n }\n}\n\n## Minimal Service GraphQL\n\nmutation DeployService($workspaceID: ID!, $alias: String!) {\n createDeployment(input: { workspaceID: $workspaceID, kind: SERVICE, alias: $alias }) {\n deployment { id url alias kind serviceMode healthPath status filesCount sizeBytes createdAt }\n }\n}\n\nmutation SetRuntimeConfig($workspaceID: ID!) {\n setWorkspaceEnv(input: { workspaceID: $workspaceID, name: \"PUBLIC_GREETING\", value: \"hello\" }) {\n env { name value secret updatedAt }\n }\n setWorkspaceSecret(input: { workspaceID: $workspaceID, name: \"API_TOKEN\", value: \"secret\" }) {\n env { name value secret updatedAt }\n }\n}\n\nmutation DeployAlwaysOnService($workspaceID: ID!, $alias: String!) {\n createDeployment(input: { workspaceID: $workspaceID, kind: SERVICE, alias: $alias, serviceMode: ALWAYS_ON, healthPath: \"/health\" }) {\n deployment { id url alias kind serviceMode healthPath status createdAt }\n }\n}\n\nquery DeploymentEvents($deploymentID: ID!) {\n deploymentEventsPage(input: { deploymentID: $deploymentID, limit: 20, offset: 0 }) {\n total\n hasMore\n nextOffset\n items {\n type\n rawType\n severity\n message\n vmID\n createdAt\n }\n }\n}\n\nquery ServiceStatus($deploymentID: ID!) {\n serviceStatus(deploymentID: $deploymentID) {\n deploymentID\n running\n health\n vmID\n inFlight\n restartCount\n lastError\n }\n}\n\nquery ServiceLogs($deploymentID: ID!) {\n serviceLogs(deploymentID: $deploymentID, tailBytes: 16384) {\n stdout\n stderr\n truncated\n stdoutBytes\n stderrBytes\n tailBytes\n }\n}\n\nmutation RestartService($deploymentID: ID!) {\n restartService(input: { deploymentID: $deploymentID }) {\n deploymentID\n running\n health\n vmID\n restartCount\n }\n}\n\n## App State (durable per-app storage)\n\nA SERVICE deployment with an alias gets a durable key/value store keyed by the\napp's stable alias, so data survives redeploys and VM restarts. It is the place\nto keep app data (todo lists, counters, user records). Two env vars are injected\ninto the running VM at boot (only for an aliased SERVICE):\n\n- KOO_STATE_URL: base URL of this app's state API (e.g. http://10.254.0.1:9000).\n- KOO_STATE_TOKEN: per-alias bearer for every state call (also used for streams).\n\nThe state HTTP API (call it from inside server.ts; every call carries\nAuthorization: Bearer ${KOO_STATE_TOKEN}):\n\n- GET ${KOO_STATE_URL}/state/\u003ckey\u003e -\u003e 200 with the raw stored bytes, or 404.\n- PUT ${KOO_STATE_URL}/state/\u003ckey\u003e body = raw bytes -\u003e 204. (\u003c=64 KiB/value.)\n- DELETE ${KOO_STATE_URL}/state/\u003ckey\u003e -\u003e 204.\n- GET ${KOO_STATE_URL}/state -\u003e 200 {\"keys\":[...]} (key names only).\n\nStore JSON by writing JSON.stringify(value) and reading JSON.parse(text). Keys\nare app-chosen strings. Alias-less deployments get neither var; fall back to\nin-memory only for local dev.\n\nExample (persist a list across requests):\n\n const URL = process.env.KOO_STATE_URL, TOK = process.env.KOO_STATE_TOKEN;\n const auth = { authorization: `Bearer ${TOK}` };\n async function load() {\n const r = await fetch(`${URL}/state/todos`, { headers: auth });\n return r.status === 404 ? [] : JSON.parse(await r.text());\n }\n async function save(v) {\n await fetch(`${URL}/state/todos`, { method: \"PUT\",\n headers: { ...auth, \"content-type\": \"application/json\" },\n body: JSON.stringify(v) });\n }\n\nAgents can also seed or inspect an app's state out-of-band with the MCP tools\nget_app_state / set_app_state / list_app_state / delete_app_state (by alias).\n\n## Apps can be MCP servers\n\nA SERVICE app can itself be a remote MCP server: serve Streamable HTTP MCP from\nserver.ts (handle POST with JSON-RPC: initialize, tools/list, tools/call) and\nkeep data in App State above. Deploy it always_on with an alias and connect an\nMCP client (e.g. Claude Desktop) to the public URL https://app.unfazed.dev/\u003calias\u003e/.\nTool results may include an embedded resource of mimeType text/html (a ui://\nresource) that MCP-Apps-capable hosts render as an interactive card; plain hosts\nshow the text content.\n\n## Realtime Streams\n\nA SERVICE deployment with an alias gets a per-alias durable-streams primitive\nfor pushing realtime updates (UI patches, progress, events) to browsers. It is\ninjected as runtime env, alongside the app-state vars:\n\n- KOO_STREAMS_URL: base URL of this app's stream producer surface.\n- KOO_STATE_TOKEN: the per-alias bearer used for both state and streams writes.\n\nAlias-less deployments get neither (streams require a stable alias).\n\nThe app WRITES (producer, server-side only):\n\n- Create a stream once: PUT KOO_STREAMS_URL/\u003cpath\u003e\n- Append a message: POST KOO_STREAMS_URL/\u003cpath\u003e\n- Both carry Authorization: Bearer ${KOO_STATE_TOKEN}.\n- Use Content-Type: application/json for JSON patches; the body is one JSON\n value or an array of values, stored as discrete messages.\n- Writes are namespaced to the app's own alias automatically; the app only ever\n uses its own stream names and can never touch another app's streams.\n\nBrowsers SUBSCRIBE (consumer, read-only) at the app-relative path:\n\n- _streams/\u003cpath\u003e under https://app.unfazed.dev/\u003calias\u003e/\n- Catch-up history: GET _streams/\u003cpath\u003e?offset=-1\n- Live SSE: new EventSource(\"_streams/\u003cpath\u003e?offset=-1\u0026live=sse\"), reading\n \"data\" events; JSON streams deliver each event as a one-element JSON array.\n- Live long-poll: GET _streams/\u003cpath\u003e?offset=\u003coffset\u003e\u0026live=long-poll\n- Reads are resumable by the Stream-Next-Offset returned on each response.\n- The public path is read-only: only GET and HEAD are allowed (writes are\n app-only, via the bearer token); other methods return 405.\n\nExample: an app appends UI patches to its \"ui\" stream with\nPOST KOO_STREAMS_URL/ui, and its HTML page renders them live by subscribing to\n_streams/ui over SSE. See examples/streams-ui.ts.\n\nStreams are in-memory for now: they do not survive a koo restart. Treat them as\nlive transport, and keep durable state in app state or your own store.\n\n## Constraints\n\n- Deployment kinds are STATIC and SERVICE.\n- Workspace file paths must be relative and cannot contain ..\n- Aliases may contain letters, digits, - and _ only.\n- The GraphQL file API currently accepts text content.\n- Do not put secrets into static deployment files.\n- SERVICE deployments require server.ts and run as small Bun HTTP services.\n- Runtime env names must be safe env identifiers. Reserved names such as KOO_*,\n HOST, PORT, PATH, LD_*, DYLD_*, and BUN_* are rejected.\n- setWorkspaceSecret/set_workspace_secret values are encrypted at rest and are\n injected only into SERVICE runtime VMs.\n- SERVICE serviceMode defaults to ON_DEMAND. ALWAYS_ON starts the VM immediately,\n keeps it warm, restarts it after watchdog failure, and replaces older always-on\n VMs when the same alias is redeployed.\n- Self-registered agent tokens are limited to 5 workspaces and 5 SERVICE\n deployments. STATIC deployments are not capped.\n"
}
]
}create_workspace(name: "guestbook")
{
"name": "guestbook"
}{
"created_at": "2026-06-12T04:43:56Z",
"id": "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a",
"name": "guestbook",
"updated_at": "2026-06-12T04:43:56Z"
}write_workspace_text_file(workspace_id: "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a", path: "server.ts",…)
{
"workspace_id": "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a",
"path": "server.ts",
"text": "// Guestbook — single-file Bun service.\n// Entries persist in Unfazed app state (KOO_STATE_URL), keyed by alias,\n// so they survive VM restarts and redeploys.\n\nconst STATE_URL = process.env.KOO_STATE_URL;\nconst STATE_TOKEN = process.env.KOO_STATE_TOKEN;\nconst KEY = \"entries\";\nconst MAX_ENTRIES = 120; // keeps the stored value well under the 64 KiB state limit\nconst MAX_NAME = 40;\nconst MAX_MESSAGE = 280;\n\ntype Entry = { name: string; message: string; ts: number };\n\nconst auth = { authorization: `Bearer ${STATE_TOKEN}` };\nlet memory: Entry[] = []; // fallback for alias-less local dev only\n\nasync function loadEntries(): Promise\u003cEntry[]\u003e {\n if (!STATE_URL) return memory;\n const r = await fetch(`${STATE_URL}/state/${KEY}`, { headers: auth });\n if (r.status === 404) return [];\n if (!r.ok) throw new Error(`state read failed: ${r.status}`);\n return JSON.parse(await r.text());\n}\n\nasync function saveEntries(entries: Entry[]): Promise\u003cvoid\u003e {\n if (!STATE_URL) {\n memory = entries;\n return;\n }\n const r = await fetch(`${STATE_URL}/state/${KEY}`, {\n method: \"PUT\",\n headers: { ...auth, \"content-type\": \"application/json\" },\n body: JSON.stringify(entries),\n });\n if (!r.ok \u0026\u0026 r.status !== 204) throw new Error(`state write failed: ${r.status}`);\n}\n\n// Serialize writes so concurrent signers can't clobber each other's\n// read-modify-write (single always-on VM, so an in-process queue suffices).\nlet writeChain: Promise\u003cunknown\u003e = Promise.resolve();\nfunction enqueue\u003cT\u003e(job: () =\u003e Promise\u003cT\u003e): Promise\u003cT\u003e {\n const next = writeChain.then(job, job);\n writeChain = next.catch(() =\u003e {});\n return next;\n}\n\nfunction json(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\" },\n });\n}\n\nasync function addEntry(req: Request): Promise\u003cResponse\u003e {\n let body: unknown;\n try {\n body = await req.json();\n } catch {\n return json({ error: \"invalid JSON\" }, 400);\n }\n const { name, message, website } = (body ?? {}) as Record\u003cstring, unknown\u003e;\n if (typeof website === \"string\" \u0026\u0026 website.trim() !== \"\") {\n // honeypot field — silently accept bots without storing anything\n return json({ ok: true });\n }\n const cleanName = typeof name === \"string\" ? name.trim().replace(/\\s+/g, \" \") : \"\";\n const cleanMessage = typeof message === \"string\" ? message.trim() : \"\";\n if (!cleanName || cleanName.length \u003e MAX_NAME) {\n return json({ error: `name must be 1–${MAX_NAME} characters` }, 400);\n }\n if (!cleanMessage || cleanMessage.length \u003e MAX_MESSAGE) {\n return json({ error: `message must be 1–${MAX_MESSAGE} characters` }, 400);\n }\n const entry: Entry = { name: cleanName, message: cleanMessage, ts: Date.now() };\n await enqueue(async () =\u003e {\n const entries = await loadEntries();\n entries.unshift(entry);\n await saveEntries(entries.slice(0, MAX_ENTRIES));\n });\n return json({ ok: true, entry }, 201);\n}\n\nexport default {\n async fetch(req: Request): Promise\u003cResponse\u003e {\n const url = new URL(req.url);\n // The /a/\u003calias\u003e prefix may or may not be stripped by the proxy; route on the tail.\n const path = url.pathname.replace(/^\\/a\\/[^/]+/, \"\") || \"/\";\n\n if (path === \"/health\") return new Response(\"ok\");\n if (path === \"/entries\") {\n if (req.method === \"GET\") {\n try {\n return json(await loadEntries());\n } catch (err) {\n return json({ error: String(err) }, 500);\n }\n }\n if (req.method === \"POST\") {\n try {\n return await addEntry(req);\n } catch (err) {\n return json({ error: String(err) }, 500);\n }\n }\n return new Response(\"method not allowed\", { status: 405 });\n }\n if (path === \"/\" \u0026\u0026 req.method === \"GET\") {\n return new Response(PAGE, {\n headers: { \"content-type\": \"text/html; charset=utf-8\" },\n });\n }\n return new Response(\"not found\", { status: 404 });\n },\n};\n\nconst PAGE = `\u003c!doctype html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n\u003cmeta charset=\"utf-8\"\u003e\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\n\u003ctitle\u003eGuestbook\u003c/title\u003e\n\u003clink rel=\"preconnect\" href=\"https://fonts.googleapis.com\"\u003e\n\u003clink rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin\u003e\n\u003clink href=\"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,900\u0026family=Caveat:wght@500;700\u0026family=IBM+Plex+Mono:wght@400;500\u0026display=swap\" rel=\"stylesheet\"\u003e\n\u003cstyle\u003e\n :root {\n --paper: #f4eee2;\n --paper-deep: #ece3d0;\n --ink: #2b2620;\n --ink-soft: #6f6354;\n --rust: #b34a2e;\n --rust-deep: #8e3a23;\n --line: rgba(43, 38, 32, 0.14);\n }\n * { box-sizing: border-box; margin: 0; padding: 0; }\n html { scroll-behavior: smooth; }\n body {\n font-family: \"Fraunces\", Georgia, serif;\n background:\n radial-gradient(1100px 500px at 85% -10%, rgba(179, 74, 46, 0.10), transparent 60%),\n radial-gradient(900px 600px at -10% 110%, rgba(120, 100, 70, 0.12), transparent 55%),\n var(--paper);\n color: var(--ink);\n min-height: 100vh;\n }\n /* grain overlay */\n body::before {\n content: \"\";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 1;\n opacity: 0.5;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\");\n }\n main {\n position: relative;\n z-index: 2;\n max-width: 680px;\n margin: 0 auto;\n padding: 56px 24px 120px;\n }\n header { margin-bottom: 52px; animation: rise 0.7s ease both; }\n .stamp {\n display: inline-block;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 11px;\n letter-spacing: 0.22em;\n text-transform: uppercase;\n color: var(--rust-deep);\n border: 1.5px solid var(--rust-deep);\n border-radius: 3px;\n padding: 5px 10px 4px;\n transform: rotate(-2deg);\n margin-bottom: 22px;\n opacity: 0.85;\n }\n h1 {\n font-size: clamp(52px, 11vw, 88px);\n font-weight: 900;\n line-height: 0.95;\n letter-spacing: -0.02em;\n }\n h1 em {\n font-style: italic;\n font-weight: 400;\n color: var(--rust);\n }\n .sub {\n margin-top: 18px;\n font-size: 17px;\n font-style: italic;\n color: var(--ink-soft);\n max-width: 42ch;\n }\n /* ---- form ---- */\n form {\n position: relative;\n background: #fffdf7;\n border: 1px solid var(--line);\n border-radius: 4px;\n padding: 26px 26px 22px;\n box-shadow: 0 1px 0 rgba(43,38,32,0.08), 0 12px 30px -18px rgba(43,38,32,0.45);\n transform: rotate(-0.4deg);\n margin-bottom: 72px;\n animation: rise 0.7s 0.12s ease both;\n }\n form::after { /* tape */\n content: \"\";\n position: absolute;\n top: -12px;\n left: 50%;\n width: 92px;\n height: 24px;\n transform: translateX(-50%) rotate(1.5deg);\n background: rgba(179, 74, 46, 0.18);\n border-left: 1px dashed rgba(142, 58, 35, 0.35);\n border-right: 1px dashed rgba(142, 58, 35, 0.35);\n }\n label {\n display: block;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 11px;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--ink-soft);\n margin: 18px 0 6px;\n }\n label:first-of-type { margin-top: 0; }\n input[type=\"text\"], textarea {\n width: 100%;\n font-family: \"Caveat\", cursive;\n font-size: 24px;\n color: var(--ink);\n background: transparent;\n border: 0;\n border-bottom: 1.5px solid var(--line);\n padding: 2px 2px 6px;\n outline: none;\n transition: border-color 0.2s ease;\n }\n textarea { resize: vertical; min-height: 84px; line-height: 1.45; }\n input[type=\"text\"]:focus, textarea:focus { border-bottom-color: var(--rust); }\n .hp { position: absolute; left: -9999px; top: -9999px; }\n .form-foot {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n margin-top: 20px;\n }\n .count {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 12px;\n color: var(--ink-soft);\n }\n .count.over { color: var(--rust-deep); font-weight: 500; }\n button {\n font-family: \"Fraunces\", serif;\n font-weight: 600;\n font-size: 16px;\n color: var(--paper);\n background: var(--ink);\n border: 0;\n border-radius: 3px;\n padding: 12px 26px;\n cursor: pointer;\n transition: transform 0.15s ease, background 0.15s ease;\n }\n button:hover { background: var(--rust-deep); transform: translateY(-1px) rotate(-0.5deg); }\n button:active { transform: translateY(0); }\n button:disabled { opacity: 0.5; cursor: wait; transform: none; }\n .notice {\n margin-top: 14px;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 12px;\n min-height: 16px;\n }\n .notice.err { color: var(--rust-deep); }\n .notice.ok { color: #4a6741; }\n /* ---- entries ---- */\n .ledger-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n border-bottom: 2px solid var(--ink);\n padding-bottom: 10px;\n margin-bottom: 34px;\n }\n .ledger-head h2 { font-size: 24px; font-weight: 600; font-style: italic; }\n .ledger-head .tally {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 12px;\n letter-spacing: 0.14em;\n color: var(--ink-soft);\n }\n #entries { list-style: none; display: flex; flex-direction: column; gap: 26px; }\n .entry {\n background: #fffdf7;\n border: 1px solid var(--line);\n border-radius: 3px;\n padding: 18px 22px 16px;\n box-shadow: 0 10px 24px -20px rgba(43,38,32,0.55);\n animation: rise 0.5s ease both;\n }\n .entry:nth-child(odd) { transform: rotate(0.5deg); }\n .entry:nth-child(even) { transform: rotate(-0.6deg); margin-left: 12px; }\n .entry:nth-child(3n) { background: var(--paper-deep); }\n .entry .msg {\n font-family: \"Caveat\", cursive;\n font-size: 25px;\n line-height: 1.35;\n overflow-wrap: anywhere;\n }\n .entry .meta {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n margin-top: 12px;\n padding-top: 10px;\n border-top: 1px dashed var(--line);\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 12px;\n color: var(--ink-soft);\n }\n .entry .who { font-weight: 500; color: var(--rust-deep); overflow-wrap: anywhere; }\n .empty {\n text-align: center;\n font-style: italic;\n color: var(--ink-soft);\n padding: 40px 0;\n font-size: 18px;\n }\n @keyframes rise {\n from { opacity: 0; transform: translateY(14px); }\n to { opacity: 1; transform: translateY(0); }\n }\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n\u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\u003cmain\u003e\n \u003cheader\u003e\n \u003cspan class=\"stamp\"\u003eest. 2026 \u0026mdash; visitors\u0026rsquo; ledger\u003c/span\u003e\n \u003ch1\u003eSign the\u003cbr\u003e\u003cem\u003eguestbook\u003c/em\u003e\u003c/h1\u003e\n \u003cp class=\"sub\"\u003eYou found your way here \u0026mdash; leave a trace before you go.\u003c/p\u003e\n \u003c/header\u003e\n\n \u003cform id=\"form\" autocomplete=\"off\"\u003e\n \u003clabel for=\"name\"\u003eYour name\u003c/label\u003e\n \u003cinput id=\"name\" type=\"text\" maxlength=\"40\" required placeholder=\"A. Wanderer\"\u003e\n \u003clabel for=\"message\"\u003eYour message\u003c/label\u003e\n \u003ctextarea id=\"message\" maxlength=\"280\" required placeholder=\"lovely corner of the internet\u0026hellip;\"\u003e\u003c/textarea\u003e\n \u003cinput class=\"hp\" id=\"website\" type=\"text\" tabindex=\"-1\" aria-hidden=\"true\"\u003e\n \u003cdiv class=\"form-foot\"\u003e\n \u003cspan class=\"count\" id=\"count\"\u003e0 / 280\u003c/span\u003e\n \u003cbutton type=\"submit\" id=\"submit\"\u003eSign the book\u003c/button\u003e\n \u003c/div\u003e\n \u003cp class=\"notice\" id=\"notice\" role=\"status\"\u003e\u003c/p\u003e\n \u003c/form\u003e\n\n \u003cdiv class=\"ledger-head\"\u003e\n \u003ch2\u003eThe ledger\u003c/h2\u003e\n \u003cspan class=\"tally\" id=\"tally\"\u003e\u0026mdash;\u003c/span\u003e\n \u003c/div\u003e\n \u003cul id=\"entries\"\u003e\u003cli class=\"empty\"\u003eLoading the ledger\u0026hellip;\u003c/li\u003e\u003c/ul\u003e\n\u003c/main\u003e\n\u003cscript\u003e\n const $ = (id) =\u003e document.getElementById(id);\n const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" });\n\n function render(entries) {\n const list = $(\"entries\");\n list.textContent = \"\";\n $(\"tally\").textContent = entries.length === 1 ? \"1 signature\" : entries.length + \" signatures\";\n if (!entries.length) {\n const li = document.createElement(\"li\");\n li.className = \"empty\";\n li.textContent = \"No one has signed yet. Be the first.\";\n list.appendChild(li);\n return;\n }\n for (const [i, e] of entries.entries()) {\n const li = document.createElement(\"li\");\n li.className = \"entry\";\n li.style.animationDelay = Math.min(i * 60, 600) + \"ms\";\n const msg = document.createElement(\"p\");\n msg.className = \"msg\";\n msg.textContent = \"\\\\u201C\" + e.message + \"\\\\u201D\";\n const meta = document.createElement(\"div\");\n meta.className = \"meta\";\n const who = document.createElement(\"span\");\n who.className = \"who\";\n who.textContent = \"\\\\u2014 \" + e.name;\n const when = document.createElement(\"time\");\n when.textContent = fmt.format(new Date(e.ts));\n meta.append(who, when);\n li.append(msg, meta);\n list.appendChild(li);\n }\n }\n\n async function load() {\n try {\n const r = await fetch(\"entries\");\n if (!r.ok) throw new Error(\"HTTP \" + r.status);\n render(await r.json());\n } catch (err) {\n $(\"entries\").innerHTML = '\u003cli class=\"empty\"\u003eCould not load the ledger. Refresh to retry.\u003c/li\u003e';\n }\n }\n\n $(\"message\").addEventListener(\"input\", () =\u003e {\n const n = $(\"message\").value.length;\n const el = $(\"count\");\n el.textContent = n + \" / 280\";\n el.classList.toggle(\"over\", n \u003e= 280);\n });\n\n $(\"form\").addEventListener(\"submit\", async (ev) =\u003e {\n ev.preventDefault();\n const btn = $(\"submit\"), notice = $(\"notice\");\n btn.disabled = true;\n notice.className = \"notice\";\n notice.textContent = \"\";\n try {\n const r = await fetch(\"entries\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n name: $(\"name\").value,\n message: $(\"message\").value,\n website: $(\"website\").value,\n }),\n });\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || \"HTTP \" + r.status);\n $(\"message\").value = \"\";\n $(\"count\").textContent = \"0 / 280\";\n notice.className = \"notice ok\";\n notice.textContent = \"signed. thank you for stopping by.\";\n await load();\n } catch (err) {\n notice.className = \"notice err\";\n notice.textContent = String(err.message || err);\n } finally {\n btn.disabled = false;\n }\n });\n\n load();\n\u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e`;\n"
}{
"path": "server.ts",
"sha256": "81e2215d08d8c8b9fe0ab2f7a019ec3f1608327c504c75a4e1755ff92bcdb018",
"size": "14.3 KB",
"size_bytes": 14625,
"updated_at": "2026-06-12T04:45:16Z"
}create_service_deployment(workspace_id: "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a", alias: "guestbook"…)
{
"workspace_id": "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a",
"alias": "guestbook",
"service_mode": "always_on",
"health_path": "/health"
}{
"alias": "guestbook",
"created_at": "2026-06-12T04:45:23Z",
"files_count": 1,
"health_path": "/health",
"id": "837ee012-57c4-444f-ba51-5cb2dcdaf0a3",
"kind": "SERVICE",
"service_mode": "ALWAYS_ON",
"size": "14.3 KB",
"size_bytes": 14625,
"spa_mode": false,
"status": "READY",
"url": "https://app.unfazed.dev/guestbook/",
"workspace_id": "1b8bc32e-e8ba-47ed-9513-f8044e5fed4a"
}Bash(command: "curl -s -o /dev/null -w \"page: %{http_co…", …)
{
"command": "curl -s -o /dev/null -w \"page: %{http_code}\\n\" https://app.unfazed.dev/guestbook/ \u0026\u0026 curl -s https://app.unfazed.dev/guestbook/health -w \" \u003c- health: %{http_code}\\n\" \u0026\u0026 curl -s -X POST https://app.unfazed.dev/guestbook/entries -H 'content-type: application/json' -d '{\"name\":\"Test Visitor\",\"message\":\"checking the ink works\"}' -w \"\\npost: %{http_code}\\n\" \u0026\u0026 curl -s https://app.unfazed.dev/guestbook/entries",
"description": "Smoke-test deployed guestbook endpoints"
}page: 200
ok <- health: 200
{"ok":true,"entry":{"name":"Test Visitor","message":"checking the ink works","ts":1781239541535}}
post: 201
[{"name":"Test Visitor","message":"checking the ink works","ts":1781239541535}]All endpoints work. Now let me verify the persistence claim for real — restart the service VM and confirm the entry survives — then wipe the test entry so the book starts clean.
ToolSearch(query: "select:mcp__unfazed__set_app_state,mcp__…", max_results: 2)
{
"query": "select:mcp__unfazed__set_app_state,mcp__unfazed__restart_service",
"max_results": 2
}[
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__set_app_state"
},
{
"type": "tool_reference",
"tool_name": "mcp__unfazed__restart_service"
}
]restart_service(deployment_id: "837ee012-57c4-444f-ba51-5cb2dcdaf0a3")
{
"deployment_id": "837ee012-57c4-444f-ba51-5cb2dcdaf0a3"
}{
"alias": "guestbook",
"always_on": true,
"deployment_id": "837ee012-57c4-444f-ba51-5cb2dcdaf0a3",
"health": "running",
"in_flight": 0,
"last_stopped_at": "2026-06-12T04:45:53Z",
"last_used_at": "2026-06-12T04:45:53Z",
"restart_count": 1,
"running": true,
"service_mode": "ALWAYS_ON",
"started_at": "2026-06-12T04:45:53Z",
"vm_id": "fbfb2110-7bd0-4925-8080-94b3eca5f2f3"
}Bash(command: "curl -s https://app.unfazed.dev/guestboo…", …)
{
"command": "curl -s https://app.unfazed.dev/guestbook/entries",
"description": "Check entries survived the VM restart"
}[
{
"name": "Test Visitor",
"message": "checking the ink works",
"ts": 1781239541535
}
]Entry survived the restart. Clearing the test entry now.
set_app_state(alias: "guestbook", key: "entries", value: "[]")
{
"alias": "guestbook",
"key": "entries",
"value": "[]"
}{
"ok": true
}Bash(command: "curl -s https://app.unfazed.dev/guestboo…", …)
{
"command": "curl -s https://app.unfazed.dev/guestbook/entries",
"description": "Confirm guestbook starts empty"
}[]
Your guestbook is live: https://app.unfazed.dev/guestbook
It's deployed on Unfazed as an always-on service (with a health check, so the platform auto-restarts it if it ever dies). Entries are stored in Unfazed's durable per-app state, which is keyed to the guestbook alias rather than the VM — no database needed. I verified this end-to-end: posted a test entry, force-restarted the VM, confirmed the entry was still there, then wiped it so your book starts empty.
A few details worth knowing:
- The page: a warm paper-ledger design — ink serif headings, entries rendered as slightly tilted handwritten note cards with a running signature tally. Visitors get a name field (40 chars max) and message field (280 chars max with a live counter).
- Safety basics: input is validated server-side, rendered with
textContent(no HTML injection), and there's a hidden honeypot field that silently swallows naive spam bots. - Capacity: the store keeps the 120 most recent entries (the per-key storage limit is 64 KiB); older ones fall off the bottom. Concurrent signatures are serialized server-side so none get lost.
- Redeploys: if you later want to tweak the design, redeploying the same alias keeps all entries — the state lives outside the deployment. Workspace ID is
1b8bc32e-e8ba-47ed-9513-f8044e5fed4a, deployment ID837ee012-57c4-444f-ba51-5cb2dcdaf0a3.
To embed it in your personal site, just link to the URL above or drop it in an <iframe>.