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.
A private zine shows "you need an invite link" when the visitor's browser is not signed in to the owning account. That is the normal state for a zine an agent just created: you hold the account, their browser does not.
Call zine_login_link and give them the link. It is good for 30 minutes and
several opens, so verifying it works does not spend it. After they open it once,
the zine's own URL works on its own from that browser.
Do not delete and redeploy to work around this, and do not assume the zine is broken. Nothing is wrong.
A zine created by an agent for someone with no account is deleted after 24 hours, along with its data. Opening it pushes the deadline out; only claiming removes it.
So when you create a zine, say this to the user plainly β do not bury it:
It's live at
. It's temporary until you claim it β open the link and it will offer to keep it permanently, one tap, no email or password.
The link deploy_zine returns is both the sign-in link and the claim link:
opening it signs their browser in and offers a passkey (Face ID or fingerprint).
zine_login_link mints another whenever they need one β it is good for 30
minutes and several opens, so checking it works does not spend it.
zine_status shows how long is left. A zine whose owner has claimed their
account never expires.
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.sql([{sql, args}, ...]) // one round trip, one transaction
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:
It is a network call (~130ms warm, ~350ms cold), not a local database.
Pass an array to batch. zine.sql([{sql, args}, {sql, args}]) runs them in
one round trip, inside a transaction, and returns one result per statement.
Statements run sequentially, not prepared up front, so a batch may create a
table and then read from it β this is verified, not assumed:
const [, , recent] = await zine.sql([
{ sql: 'CREATE TABLE IF NOT EXISTS sets (id INTEGER PRIMARY KEY, ex TEXT, kg REAL)' },
{ sql: 'INSERT INTO sets (ex, kg) VALUES (?, ?)', args: ['squat', 100] },
{ sql: 'SELECT ex, kg FROM sets ORDER BY id DESC LIMIT 10' },
]);
Use it for setup on first paint, and for anything that must succeed or fail together β two separate calls are two separate transactions, so a delete across two tables can orphan rows halfway through. If any statement fails the whole batch rolls back, including statements that already succeeded. Limit 50.
A single call takes one statement; multi-statement SQL in one string is rejected. Use the array form instead.
No lastInsertRowid. Each single 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, or batch.
Returns {rows, columns, rowsAffected}; the array form returns an array of
those. Rows are objects keyed by column.
Independent reads can also go through Promise.all, but batching is better:
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.
<!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.copy_files copies files from one zine into another that already exists,
keeping the destination's URL, database, members and history. That is the tool
for pushing an update from one zine to another, or pulling a starter's code into
a zine someone already has.
Do not use fork_zine for this β it always creates a NEW zine with an empty
database, so using it to update something loses the data. And do not re-send the
whole document through deploy_zine by hand: the point of copy_files is that
the bytes never pass through you, so nothing can be garbled in transit.
Both deploy_zine and copy_files return each file with its byte count, so
you can confirm what landed without a second call.
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). Pass an ARRAY of {sql, args} to run several statements in one round trip inside a transaction β use that for first-paint setup and for anything that must succeed or fail together. A single call takes one statement and has 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 files from one zine to another that already exists, keeping the destinationβs URL, database, members and history. Use this to push an update from one zine to another, or to pull a starterβs code into a zine you already have β fork_zine only ever makes a NEW zine with an empty database, so it cannot do this. Copies every file by default; pass paths for a subset. Returns each file with its byte count so you can verify it landed.
Arguments:
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:
Get a fresh link that signs the userβs browser in to this account. Use it whenever they cannot open their own zine β a private zine showing "you need an invite link" means their browser simply is not signed in yet, not that anything is broken. Also use it when they want their zines on a second device. The link is good for 30 minutes and a handful of opens, so checking that it works does not spend it. Requires the account token, so it grants nothing you do not already have.
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: