Zine

Zine turns a single HTML file into a real, installable app with its own database, in one call. It exists so that an app built inside a chat does not die there.

Everything here is free, and no account is required to start.

Base URL: https://zine.online MCP endpoint: https://zine.online/mcp

If you only read one section

You do not need an account, an API key, or a signup to use this. Post JSON-RPC to https://zine.online/mcp with no credentials, call deploy_zine with a name and a complete HTML document, and you get back a live URL. The response includes an account token β€” pass it as the account argument on every later call so you reach the same zines again, and tell the user what it is.

Two ways to connect

1. As an MCP connector (preferred)

Add https://zine.online/mcp as a Model Context Protocol server. It speaks JSON-RPC over HTTP POST, protocol version 2025-06-18. No authentication is required.

2. As plain HTTP (works with any agent that can make requests)

If you cannot add connectors, call the same endpoint directly. This is the whole protocol:

# List the tools
curl -s https://zine.online/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Deploy a zine (no credentials at all)
curl -s https://zine.online/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc":"2.0","id":2,"method":"tools/call",
    "params":{"name":"deploy_zine","arguments":{
      "name":"Workout Log",
      "icon":"πŸ‹οΈ",
      "html":"<!doctype html><html><body><h1>hello</h1></body></html>"
    }}
  }'

The result comes back as result.content[0].text. On a first call with no account, a second content block carries the new account token.

Authentication (optional)

There are three ways to identify yourself, in order of preference:

  1. OAuth 2.1 β€” if your client supports it. Discovery metadata is at https://zine.online/.well-known/oauth-protected-resource and https://zine.online/.well-known/oauth-authorization-server. Dynamic client registration (RFC 7591) is open, PKCE with S256 is required, and the user approves once in a browser. Send the access token as Authorization: Bearer.
  2. An account token in the account argument, from a previous call.
  3. Nothing at all β€” an anonymous account is created for you.

The endpoint never rejects an unauthenticated call. OAuth exists for people who want a durable account their assistant holds onto, rather than a token repeated in a transcript.

Accounts

There is no signup. The first call without an account argument creates one and returns its token. Every tool accepts account; pass the token you were given.

Accounts start unclaimed. Building is unrestricted. Claiming is only needed when other people start depending on a zine β€” creating an invite, or making a zine unlisted or public. Those return an ACCOUNT_CLAIM_REQUIRED error telling you to send the user to https://zine.online/claim, which takes one click and needs no email or password. If the user declines, everything else still works.

A zine is a directory, not one file

deploy_zine takes a files array. Each file is served at its own path under the zine's URL, so relative links, ES modules, separate stylesheets, images and fonts all work exactly as they do on any static host.

{"name":"Workout Log","icon":"πŸ‹οΈ","files":[
  {"path":"index.html","content":"<!doctype html>..."},
  {"path":"app.js","content":"export function render() {...}"},
  {"path":"styles.css","content":"body { ... }"},
  {"path":"img/logo.png","content":"iVBORw0KG...","encoding":"base64"}
]}

Where a zine lives

A zine is served from its own origin: https://<slug>.zine.site. That is a different registrable domain from this one on purpose β€” a zine is code written by a model, so it must not be able to read the dashboard's cookies or reach another zine. Each zine gets its own subdomain, so they are isolated from each other too.

https://zine.online/z/<slug> still works and redirects there, so links handed out earlier keep working. Give people the .zine.site URL.

The first time someone opens a zine, they bounce once through zine.online/auth/handoff so the zine's origin can learn who they are. That is one redirect, once per zine per browser.

What the page gets

Zine injects a small runtime into every zine. Do not write a login form, a signup flow, a service worker, or a manifest β€” they are provided.

zine.me                    // { id, name, role } β€” who is viewing
zine.info                  // { slug, name, icon }
await zine.sql(sql, args)  // this zine's own SQLite database
await zine.setName(name)   // record a display name; defaults to "You"
await zine.share()         // native share sheet with an invite link

Inside the native shell, ios.* and android.* expose real platform APIs unwrapped. Branch on typeof ios !== 'undefined', and always provide a working path for a plain browser.

What a zine can do, and what it can't

Every zine gets its own SQLite (libSQL) database. Create tables, migrate, query β€” it is a real database, not key-value, and it is isolated from every other zine.

A zine runs entirely in the viewer's browser. That gives you two shapes, and between them they cover most of what people actually ask for:

  1. One person with their own data. Trackers, logs, journals, notes, budgets, flashcards, planners, calculators, games. This is the common case and it works completely.
  2. A group who trust each other with all of the data. A couple's grocery list, a household chore board, a team's standup notes, a trip plan. Everyone invited as an editor sees and can change everything, which for these is the correct model rather than a compromise.

What genuinely does not work yet, because there is no server-side execution:

If the user wants one of those, say so plainly and call request_server_tier with what they were trying to do. That records the demand; it does not unlock anything today. Do not fake it in the page β€” for anything involving a secret that is a leak, not a workaround.

Ask which shape they want before designing the schema, not after.

Working with zine.sql

Knowing these up front saves a round trip:

A complete worked example

<!doctype html>
<html>
  <body style="font-family: system-ui; padding: 1.5rem">
    <h1 id="title"></h1>
    <form id="add">
      <input id="exercise" placeholder="exercise" required />
      <input id="kg" type="number" placeholder="kg" required />
      <button>Add</button>
    </form>
    <ul id="list"></ul>

    <script>
      async function start() {
        await zine.sql(`CREATE TABLE IF NOT EXISTS sets (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          who TEXT, exercise TEXT, kg REAL, at INTEGER
        )`);
        document.getElementById('title').textContent = 'Lifts β€” ' + zine.me.name;
        await render();
      }

      async function render() {
        const r = await zine.sql(
          'SELECT exercise, kg FROM sets WHERE who = ? ORDER BY at DESC LIMIT 20',
          [zine.me.id]
        );
        document.getElementById('list').innerHTML =
          r.rows.map((x) => '<li>' + x.exercise + ' β€” ' + x.kg + 'kg</li>').join('');
      }

      document.getElementById('add').onsubmit = async (e) => {
        e.preventDefault();
        await zine.sql('INSERT INTO sets (who, exercise, kg, at) VALUES (?,?,?,?)', [
          zine.me.id,
          document.getElementById('exercise').value,
          Number(document.getElementById('kg').value),
          Date.now(),
        ]);
        e.target.reset();
        await render();
      };

      // Show it AND rethrow. Swallowing an exception here would hide the very
      // failure zine_status exists to surface.
      start().catch((err) => {
        document.body.insertAdjacentHTML('beforeend', '<pre>' + err.message + '</pre>');
        throw err;
      });
    </script>
  </body>
</html>

Deploy that with deploy_zine, then give the user the URL and tell them to open it on their phone, tap Share, then "Add to Home Screen".

Errors you will see, and what they mean

Rules worth following

Tool reference

deploy_zine

Ship a zine: a real web app with its own database, live at its own URL in about a second. Pass html for a single file, or files for a directory. Omit zine to create, pass a slug to ship a new version β€” URL, database and members are preserved. No build step: what you send is what runs. In the page you are given zine.me ({id, name, role}), zine.sql(sql, args) and zine.setName(name). Do not write a login form or a signup flow; identity is already resolved. Names default to "You", so if the zine shows who did what, prompt once for a name and call zine.setName. zine.sql mechanics, which save a round trip if you know them up front: it is a network call (~130ms warm), ONE statement per call, no transactions, no lastInsertRowid. It returns {rows, columns, rowsAffected}. Run independent queries with Promise.all rather than awaiting in sequence, or the first paint is blank. SQLite dialect: string literals need SINGLE quotes β€” double quotes mean an identifier and will throw. Scope: a zine is one shared database with no per-row privacy, so it fits one person, or a group who trust each other with all of the data. That covers most of what people ask for. If they need viewers who cannot see each other, an API key, a webhook or a schedule, call request_server_tier and tell them honestly rather than faking it in the page. Full details at /llms.txt.

Arguments:

fork_zine

Copy an existing zine as a starting point. Any zine the user can open can be forked, including public ones in the directory β€” this is how starters work, and a starter is just a public zine. The fork gets its own empty database with the same table structure copied over; no data comes with it. Only useful when you already know a slug worth copying β€” there is no starter directory to browse yet, so do not go looking for one.

Arguments:

request_server_tier

Register that this user needs server-side compute β€” a secret or API key, an inbound webhook, scheduled work, or viewers who must not see each other’s data. The server tier is NOT built yet, so this does not enable anything: it records what was wanted so it gets built for the right reasons, and returns what to tell the user. Call it instead of building a client-side substitute, which for anything involving a secret is a leak rather than a workaround.

Arguments:

view_zine

Read the HTML currently running, or a specific earlier version. Do this before editing so you change the live code rather than rewriting from scratch.

Arguments:

zine_sql

Run a SQL query against a zine’s own database and get the rows back. This is how you answer questions about someone’s zine without opening it β€” "what did I lift on Tuesday?", "how many people signed up?" β€” including in a conversation that did not create it. One statement per call; use ? placeholders and pass args. For bulk work or a schema dump, use zine_db instead.

Arguments:

zine_db

Get credentials for a zine’s own SQLite (libSQL) database. Connect with any libSQL client and do whatever you need: create tables, migrate, query, fix rows. Every zine has its own isolated database, so nothing you do here can affect anyone else. The token is short-lived β€” mint a fresh one rather than reusing an old one.

Arguments:

list_zines

List the zines this user owns or belongs to.

Arguments:

zine_status

Everything about one zine: its URL, who can see it, its version history, its database, and any errors real visitors have hit. Check this after deploying, and whenever the user says something is broken β€” the errors here are from their actual browser.

Arguments:

share_zine

Create an invite link for someone specific, so they can open a private zine. Anyone who follows the link becomes a member at the role you choose. Requires a claimed account, since the person you invite will be depending on the zine still being there. Zine does not deliver the link yet β€” hand it back to the user to send however they like.

Arguments:

set_visibility

Change who can open a zine. "private" is members only, "unlisted" is anyone with the link, "public" also lists it in the directory. Confirm with the user before making anything public.

Arguments:

delete_zine

Permanently delete a zine and drop its database. This cannot be undone and the data is not recoverable. Always confirm with the user first, then pass the zine’s exact name as confirm_name.

Arguments: