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
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.
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.
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.
There are three ways to identify yourself, in order of preference:
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.account argument, from a previous call.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.
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.
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"}
]}
index.html is the entry point and is required..html file gets the platform shell, so multi-page zines work."encoding":"base64"._ are reserved by Zine."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.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.
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.
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:
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.
Knowing these up front saves a round trip:
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.{rows, columns, rowsAffected}. Rows are objects keyed by column.Promise.all. Awaiting migrations and seeds
in sequence leaves the page blank for over a second on first paint.no such column. This is the single most common way a
model breaks a screen here..catch
that only renders a message, it never reaches zine_status, and the user's
"it's broken" becomes unfixable.<!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".
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.account to start a fresh one.view_zine before editing an existing zine, so you change the live code
rather than rewriting it from memory.zine_status after the user says something is broken. It returns real
errors captured from their actual browser, with stack traces.zine_db if you want to work on the data directly with a libSQL client
rather than through the page.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:
files instead when you need more than one file.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:
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:
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:
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:
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 the zines this user owns or belongs to.
Arguments:
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:
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:
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:
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: