# 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:
```bash
# 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":"
hello
"
}}
}'
```
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.
```json
{"name":"Workout Log","icon":"ποΈ","files":[
{"path":"index.html","content":"..."},
{"path":"app.js","content":"export function render() {...}"},
{"path":"styles.css","content":"body { ... }"},
{"path":"img/logo.png","content":"iVBORw0KG...","encoding":"base64"}
]}
```
- `index.html` is the entry point and is required.
- Every `.html` file gets the platform shell, so multi-page zines work.
- Binary files (png, jpg, gif, webp, ico, woff, woff2) use `"encoding":"base64"`.
- Paths beginning with `_` are reserved by Zine.
- Limits: 100 files, 2MB per file, 5MB per zine.
- A deploy **replaces** the whole file set. Pass `"merge": true` to write only
the files you send and leave the rest alone β that is the cheap way to change
one file while iterating.
- `html` still works as shorthand for a single-file zine; it becomes index.html.
- `view_zine` lists every file, and takes a `path` to read one of them.
## Where a zine lives
A zine is served from its own origin: `https://.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/` 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.
```js
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:
- Anything holding a secret or API key β a browser cannot keep one
- Inbound webhooks
- Scheduled work; nothing runs while nobody has the page open
- Viewers who must not see each other's rows β a poll, a sign-up sheet, a form
taking submissions from strangers. SQLite has no row-level security, and
hiding rows in the page hides nothing from anyone who opens devtools
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:
- It is a **network call** (~130ms warm, ~350ms cold), not a local database.
- **One statement per call.** Multi-statement SQL is rejected.
- **No transactions across calls**, and no `lastInsertRowid`. Each call is its
own connection, so `SELECT last_insert_rowid()` afterwards is racy β it will
appear to work and then corrupt data under concurrency. Use an explicit id.
- Returns `{rows, columns, rowsAffected}`. Rows are objects keyed by column.
- **Run independent queries with `Promise.all`.** Awaiting migrations and seeds
in sequence leaves the page blank for over a second on first paint.
- **SQLite dialect: string literals use single quotes.** Double quotes mean an
identifier and throw `no such column`. This is the single most common way a
model breaks a screen here.
- Report errors *and* rethrow them. If you swallow an exception in a `.catch`
that only renders a message, it never reaches `zine_status`, and the user's
"it's broken" becomes unfixable.
## A complete worked example
```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
- `SERVER_TIER_REQUIRED` β needs server-side execution, which does not exist
yet. Say what cannot work and call `request_server_tier`. Never build a
client-side substitute for something involving a secret.
- `ACCOUNT_CLAIM_REQUIRED` β sharing or publishing needs a claimed account. Send
the user to https://zine.online/claim.
- "That account token is not valid" β omit `account` to start a fresh one.
## Rules worth following
- Send one complete, self-contained HTML document. No build step, no bundler, no
npm, no framework. What you send is what runs.
- Call `view_zine` before editing an existing zine, so you change the live code
rather than rewriting it from memory.
- Call `zine_status` after the user says something is broken. It returns real
errors captured from their actual browser, with stack traces.
- Re-deploying is cheap and instant. Iterate.
- Use `zine_db` if you want to work on the data directly with a libSQL client
rather than through the page.
## 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:
- zine (string, optional) β Existing slug. Omit to create a new zine.
- name (string, optional) β Display name. Required when creating.
- html (string, optional) β Shorthand for a single-file zine: this becomes index.html. Use `files` instead when you need more than one file.
- files (array, optional) β The zine as a directory. Each file is served at its own path, so relative links, ES modules, separate stylesheets and images all work normally. index.html is the entry point and is required. Paths beginning with _ are reserved.
- merge (boolean, optional) β By default a deploy replaces the whole file set. Pass true to write only the files you send and leave the rest alone β useful when changing one file.
- icon (string, optional) β A single emoji, used as the home-screen icon.
- theme (string, optional) β Hex color for the icon background and status bar.
- note (string, optional) β What changed, shown in the version list.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required) β Slug of the zine to fork.
- name (string, optional) β Name for the copy. Defaults to " copy".
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- reason (string, required) β What the user is trying to do that needs a server.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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 (string, required)
- path (string, optional) β Which file to read. Omit to list every file in the zine and return index.html.
- version (number, optional) β Omit for the version currently running. Earlier versions are listed by zine_status β re-deploying one is how you get back to a state that worked.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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 (string, required)
- sql (string, required) β One statement. SQLite dialect: string literals use single quotes, not double.
- args (array, optional) β Values bound to ? placeholders.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### list_zines
List the zines this user owns or belongs to.
Arguments:
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- to (string, optional) β Who it is for β an email address, a phone number, or a name. Used to label the invite; nothing is sent.
- role ("viewer" | "editor", optional) β viewer can open it; editor can also change it and its data.
- max_uses (number, optional) β How many people may use this link. 0 means unlimited.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- visibility ("private" | "unlisted" | "public", required)
- tagline (string, optional) β One line shown in the directory.
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.
### 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:
- zine (string, required)
- confirm_name (string, required)
- account (string, optional) β Account token from a previous call. Omit on your very first call β one will be created and returned, and you should pass it on every call after that so you reach the same zines.