What is Quickish
Quickish gives any folder of HTML a live URL. No build step, no server to run, no hosting to set up. You make a page; Quickish puts it online.
If you have an index.html, whether written by hand, exported from a tool, or generated by an AI, Quickish turns it into a real website in seconds. Drop a folder and the links inside it just work. When you want more than a static page, add one script tag and you get a backend: a database, file uploads, who's signed in, live updates, and small server functions, all with no keys to manage.
Three ways to publish
Web
Sign in and drop a folder, files, a .zip, or a single .html onto your dashboard.
CLI
One command from your terminal. Great for developers and coding agents.
AI popular
Connect Quickish to your assistant once, then just say “publish this.”
What you can build
Start with a one-page site and grow into a real app, all on the same URL:
- A landing page, a résumé, a wedding invite, a link-in-bio, published in seconds.
- A guestbook, a poll, a kanban board, a sprint-retro, using quick.db for storage and quick.realtime for live updates.
- A leaderboard or anything with real relationships, using quick.sql.
- Anything that needs a secret key or trusted logic like a checkout or a contact form, using server functions.
How to read these docs
Start here is the plain-language path with no code: put a page online, and build a working app by asking an assistant. Getting started covers the mechanics, including the command line. Guides go a step deeper on each capability. Workspace covers shared company accounts. The API reference documents every quick.* call precisely, for when you want the exact signature.
Put a page online
You do not need to be a developer to have a real web page. If you have a file, or an idea an assistant can turn into one, you can have a link to share in a couple of minutes.
Two ways in
Both give you a live page at your own Quickish address. Pick whichever fits how you work:
Drop a file
Sign in and drag a file or folder onto your dashboard. It goes live right away.
Ask an assistant popular
Tell an AI what you want. It writes the page and publishes it for you.
Drop a file or a folder
Sign in with Google, then drag what you have onto the drop area on your dashboard. There is nothing to configure. A few things you can drop:
- A folder you exported from a website builder or design tool.
- A single page a friend or a tool made for you (an
index.html). - A lone document like a PDF, a spreadsheet, or an image. Quickish shows it in a clean reader with a download button, covered in Pages & folders.
If you drop a folder, keep a file named index.html at the top so Quickish knows which page is the front door. If there is not one, it picks your most recent page and tells you which.
Or just ask an assistant
Connect Quickish to Claude or ChatGPT one time (the short setup is in Publish with AI). After that, say what you want in plain words and the page appears:
The assistant writes the page and puts it online as a private preview, a version only you can see. Look it over, ask for any changes, and when you are happy click Promote on the dashboard to make it public.
Share the link
Every page can be kept to yourself, shared with a few named people, shown to your whole company, or opened to anyone with the link. You choose per page. The options are laid out in Sharing & visibility.
Change it later
To update a page, edit your file and drop it again, or ask the assistant for another change and republish. Your files are always yours: you can pull them back down any time from your dashboard or the command line.
Build an app without code
A page can do more than sit there. With Quickish an assistant can build you a working app, save what people enter, and update live for everyone, with the database already wired up. You describe it; the assistant builds it.
What counts as an app here
Anything that remembers what people do and shows it back. A few that take minutes to make:
- An RSVP or a sign-up sheet with a live count.
- A guest book or a message wall.
- A poll that tallies votes as they come in.
- A shared board for a team, like a sprint retro or a simple kanban.
- A leaderboard for a game or a challenge.
All of them save data and update in real time for everyone on the page, with nothing for you to set up or manage.
How it works
Connect Quickish to your assistant one time (see Publish with AI). Then:
- Describe the app in plain words.
- The assistant writes it using Quickish's built-in storage and live updates, and adds a few example entries so it is not blank on first run.
- It publishes a private preview, a version only you can see.
- You look it over, ask for changes, then click Promote to share it.
Start from an app that already exists
You do not have to start from a blank page. Many Quickish apps are marked remixable: open one you like, click Remix, and you get your own private copy to change, with a fresh empty database of its own. It is the fastest way to get something working that you can then make your own. The official example apps are open source, so the whole thing is yours to read and adjust. More in Sharing & visibility.
When you want to go further
Everything the assistant builds is a normal Quickish page, and the code is yours. When you are ready for more control, a developer or a coding assistant can take it further: real relationships with quick.sql, secret keys and trusted logic with server functions, or a full walk-through of the tools in Add a backend.
Publish your first page
Get a live URL three ways: from the web, from your terminal, or by asking an AI assistant.
From the web
Sign in with Google, then on your dashboard drop a folder (or a .zip or a single .html) onto the drop area. It's live at your space URL right away. Keep an index.html at the root so the page has a homepage.
From the terminal
Install the CLI once, sign in, and publish the current folder:
$ npm i -g quickish
$ quickish login
$ quickish # publish the current folder
Publish a named sub-page by passing a name: quickish ./site deck serves at /deck/.
quickish --build to build on your machine and publish the result, or build it yourself and run quickish ./dist. If a dist/ already exists, plain quickish finds and publishes it.By asking an AI
Connect Quickish to ChatGPT or Claude once, then just say “publish this to Quickish” in any chat and your page goes live, assets and all. See Publish with AI for the full setup.
index.html, then publish it to Quickish.”What happens next
Your page gets a URL you can share immediately. From there you can change who can see it, point a custom domain at it, or add a backend to make it interactive.
Pages & folders
A page is a folder. Quickish gives it a live URL and serves every file inside.
On a personal account your home page is <you>.quickish.site/, with optional sub-pages at /name/. Publish a sub-page by naming it: quickish ./site deck serves at /deck/. (Work accounts are covered in Workspace accounts & URLs.)
Folders & index.html
Drop a whole folder and the links between files inside it just work, relative paths and all. Keep an index.html at the root so the page has a homepage. If there isn't one, Quickish picks your most recently edited HTML file as the homepage and tells you which.
Single documents render automatically
Publish a single document with no page of its own (a lone PDF, Markdown, text, CSV, or image), and Quickish builds a clean in-browser viewer for it instead of handing over a bare file. A Download button for the original is always one click away.
| File | You get |
|---|---|
.pdf | A paged PDF reader (rendered to canvas, so nothing in the file can run). |
.md .markdown | The Markdown rendered as a styled article (sanitized, no embedded scripts). |
.csv .tsv | A sortable table. |
.txt .log | A clean monospace reading view. |
| images | A centered, full-bleed viewer. |
$ quickish ./resume.pdf # a single PDF → a real PDF reader at your URL
$ quickish ./notes.md # Markdown → a styled, readable page
It only kicks in when the document is the whole publish (no index.html). The viewer inherits the page's visibility, so a private PDF stays private. Each viewer loads only what it needs, served first-party.
How many pages render
On the free plan, one HTML file per site renders (its index.html); CSS, JS, and images always load, so single-page sites work perfectly. Paid plans render every HTML page in the folder, so multi-page sites navigate freely. See Plans.
Sharing & visibility
Every page can be private, shared with named people, company-wide, or fully public.
Override it per page in the dashboard, or with a flag on the CLI:
| Mode | Who can view |
|---|---|
--private | Only you. |
--invites a@co.com,b@co.com | You and the people you list (they sign in to prove it). |
--workspace | Anyone signed in at your company domain. default · work |
--public | Anyone with the link, after a one-time content-policy consent. default · personal |
Preview, then promote
When an AI assistant publishes for you (via the Quickish connector), the new app lands as a private preview: only you can see it while you iterate, real backend and all. When it's ready, open the dashboard and hit Promote on the page card to make it live in one click.
Promoting only flips who can view it; the page and its data carry over untouched, because the preview is the page. Publishing from the CLI is direct: it uses the visibility you pass (or the account default), with no preview step.
Remix an app
See a Quickish app you like? Remix it into your own copy. On a remixable page, click the Remix button in the corner; or open your dashboard, paste the app's URL into Remix an app, and hit Remix. Either way you get a brand-new private preview under your account, with its own fresh backend, to edit and then promote.
There are plenty of Quickish sites and apps you can remix; remixable is on by default for org-wide sites and apps. A remix starts with an empty database and never copies the original's live data, unless the source marks a collection as sample data, in which case those example records come along so your copy isn't blank on first run.
A remix stays linked to the app it came from, so it's a living fork. When the creator ships changes, your dashboard shows that an update is available on the page card, and one click pulls the new version in. From the terminal it's quickish remix status to see what changed and quickish remix update <folder> to merge it into your copy (clean changes only; anything you edited yourself is left for you to reconcile).
Download your code
Your published pages are always yours to pull back down. quickish download <page> <folder> writes a page's files into a local folder, so you can edit them in your own tools and re-publish:
$ quickish download blog ./blog # pull the "blog" page into ./blog
$ quickish ./blog blog # publish your edits back
Leave the page name off to pull your home page. Together with remix, this is the whole point: fork an official app, download it, and the entire source is on your machine.
Publish with AI
AI assistants can generate a whole page, but the result usually has nowhere to live. Connect Quickish once, then just say “publish this to Quickish” and your page goes live, assets and all.
Claude · custom connector
Add Quickish as a connector and sign in once. Then publish from any chat.
- In Claude, open Settings → Connectors and choose Add custom connector.
- Set the URL to
https://quickish.website/mcpand connect. - Sign in with Quickish when prompted, then say “publish this to Quickish” in any conversation.
ChatGPT · custom GPT
Requires ChatGPT Plus. Create a GPT with one action and OAuth; you (and anyone you share it with) sign in once with Quickish, with no tokens to pass around.
- ChatGPT → Explore GPTs → Create → Configure. Name it Quickish Publisher.
- Paste instructions telling it to produce one self-contained
index.htmland callpublishToQuickish. - Under Actions, add an OpenAPI schema pointing at
https://api.quickish.websitewith aPOST /_assistant/publishoperation. - Set Authentication to OAuth, with the Authorization URL
https://quickish.website/oauth/authorize, Token URLhttps://quickish.website/oauth/token, scopepublish. - Save and Publish the GPT. Everyone you share it with signs in with their own Quickish account.
The exact instructions and schema to paste are in your dashboard under Settings → Publish with AI.
Coding agents & the CLI
Assistants with a terminal (e.g. Claude Code) can publish for you. Sign in once with quickish login, then ask:
index.html, then publish it to Quickish with the quickish CLI. Also invite alex@acme.com.”It writes the file, runs quickish (passing --invites when you name people), and hands you the live link.
Advanced · direct API
For your own scripts: grab a publish token in your dashboard under Settings → Publish with AI, then POST a self-contained page. Full details in the HTTP API reference.
$ curl -X POST https://api.quickish.website/_assistant/publish \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "html": "<!doctype html><h1>hi</h1>" }'
Returns { "url": "..." }. Optional fields: page, share (private / public / workspace), invites.
Add a backend
Any page gets a zero-config backend by adding one script tag. No keys to manage, nothing to provision.
<script src="/quick.js"></script>
That one tag gives you quick.db (a document store), quick.files (uploads), quick.identity() (the signed-in visitor), quick.realtime() (live updates), and quick.cast() (push to a screen from your phone). This page covers the document store; each API has a precise reference too.
Store and read documents
A collection is a named bucket of JSON documents. Create, list, update, remove, and subscribe for live changes:
const tasks = quick.db.collection("tasks");
await tasks.create({ title: "Ship it", done: false });
const all = await tasks.list();
await tasks.update(all[0].id, { done: true });
await tasks.remove(all[0].id);
// live updates over websockets
tasks.subscribe({ onCreate: t => console.log("new task", t) });
Filter, sort & page
list() takes an optional query so you don't have to pull the whole collection:
// open tasks, newest first, 20 at a time
await tasks.list({ where: { done: false }, order: "-created_at", limit: 20 });
// operators: gt / gte / lt / lte / ne / in
await tasks.list({ where: { votes: { gte: 3 }, status: { in: ["open", "wip"] } } });
// page with limit + offset
await tasks.list({ order: "title", limit: 20, offset: 40 });
Filters match top-level fields (max 500 rows per call). Values are always parameters, so a query can never inject anything. Full options in the quick.db reference.
Who can read and write
A collection is owned by the page that creates it. By default it's readable by your page's audience and writable only by you, so a public page can show data without letting visitors change it. Set per-operation rules when you declare it:
// signed-in visitors comment; each edits only their own; you can moderate
quick.db.collection("comments", {
read: "audience", create: "users",
update: "author", delete: ["author", "owner"],
});
The full vocabulary of who's who lives in Permissions.
Sample data for remixes
When someone remixes your app, their copy starts empty. Mark a collection as sample data and its documents are seeded into every remix, so the copy works on first run:
// example cards travel with a remix; everything else starts fresh
quick.db.collection("cards", { seed: true });
Only the owner page can set this, and only your own app's data is ever copied (up to 500 records per collection).
Reaching your data from outside
The same collections are reachable off-page (for a script or an AI agent that builds your app) using a data token scoped to one page. See the HTTP API reference. With the Claude connector you don't even need a token; Claude reads and writes through built-in tools.
Encrypted data
Store secrets on a page that even Quickish can’t read. The data is encrypted in the visitor’s browser under a key we never see, so it’s safe at rest and yours alone.
An encrypted collection is a normal quick.db collection that’s sealed. You unlock a vault (a key) with a passphrase, then read and write through it; encrypting and decrypting happen automatically in the page. Quickish only ever stores ciphertext, so a database dump or a breach reveals nothing. Exact signatures live in the quick.crypto reference.
What it’s for
- A team secret store: org-wide API keys, SSO secrets, that one shared password, kept in a page instead of a spreadsheet.
- Private notes & vaults: journals, client records, anything that should never be readable on the server.
- Sensitive form data that stays sealed end to end and is shared only with the people you choose.
The easy way
Create a vault locked by a passphrase, then use it like any collection:
// 1. create a vault, locked by a passphrase (at least 12 characters)
const vault = await quick.crypto.create("secrets", passphrase);
// 2. write, encrypted in the browser before it leaves the page
await vault.db("keys").create({ name: "stripe", value: "sk_live_…" });
// 3. read, fetched as ciphertext, decrypted locally
const keys = await vault.db("keys").list();
Later, on any device, unlock the same vault with the passphrase:
const vault = await quick.crypto.unlock("secrets", passphrase);
quick.crypto.suggest() generates a strong one; store it in a password manager, and set up recovery (below) if a team depends on it. We only require a sensible minimum length, with no “1 capital, 1 symbol” rules (those just push people to sticky notes).Share it with your team
Sharing hands someone the key, sealed to them, so it never passes through us in the clear. Each person enrolls a sharing identity once:
// each teammate, once
await quick.crypto.enroll(passphrase);
// the owner grants a teammate (offline-friendly; they pick it up next sign-in)
await vault.share("teammate@acme.com");
// the teammate opens the shared vault
const id = await quick.crypto.identity(passphrase);
const vault = await quick.crypto.unlockShared("secrets", id);
Need access right now? Someone can request it live and an online key-holder approves, comparing a short fingerprint out loud so a tampered request can’t slip through. See requestAccess and serveGrants in the reference.
Revoke & recover
Removing a grant stops handing out the key, but anyone who already had it kept a copy. Rotate to truly cut them off: it mints a new key, re-encrypts everything, and re-shares only with whoever remains.
await vault.rotate({ remove: ["former@acme.com"] });
So a lost passphrase doesn’t mean lost data, an owner can set an optional escrow passphrase: a break-glass key held out-of-band (a safe, a password manager, split among admins). It’s off by default, because it’s the one thing that can soften “we can’t read it”; its strength is exactly the strength of that passphrase.
await vault.setEscrow(escrowPassphrase); // once, to enable recovery
// later, if the normal passphrase is gone:
const vault = await quick.crypto.recoverWithEscrow("secrets", escrowPassphrase);
await vault.rekey(newPassphrase); // set a fresh passphrase
A passkey (Face ID, Touch ID, a security key) can also unlock a vault with no passphrase to remember, where the browser supports it. experimental
What’s different about a sealed collection
Because the server only sees ciphertext, a few things deliberately don’t work, and they fail loudly rather than leak:
- No server-side
where/order. Fetch rows and filter or sort after they decrypt in the page. - No pre-baking (the SEO snapshot); there’s nothing readable to bake.
- No server functions reading it; functions run on our servers, so they’re kept out of the plaintext. If a function needs a secret, copy that one value into its function secrets yourself.
- Writes must be sealed; plaintext sent to an encrypted collection is rejected.
Permissions
Two questions, for both your pages and your data: who can view it, and who can change it. Quickish picks safe answers by default, and you can override them.
The vocabulary
Both pages and collections describe access using the same principals, kinds of people relative to the page:
| Principal | Who it means |
|---|---|
owner | You, whoever published the page (plus anyone you name as an admin). |
audience | Whoever can already view the page (public → everyone, company → your domain, invited → the people you listed). |
author | The person who created a particular record. Used for “edit only your own.” |
users | Anyone signed in (with any Google account). |
workspace | People at your company (same email domain). |
public | Anyone at all, signed in or not. |
Who can edit a page
Editing a page means re-publishing over it. By default that's owner only: even a company-visible or fully public page can only be changed by you. Open it up for collaborators:
| Edit policy | Who can re-publish |
|---|---|
--edit-policy owner | Only you. default |
--editors a@co.com,b@co.com | You and the people you list. |
--edit-policy workspace | Anyone at your company domain. |
This is independent of who can view the page. Re-publishing never changes who owns a page; adding editors lets people update it, not take it over.
Who can use a collection
A collection gets a policy when you declare it, one rule per operation. Each rule is a principal or a list (allowed if you match any). write is shorthand for create + update + delete.
| Policy | Behaviour |
|---|---|
{ read:"audience", write:"owner" } | Everyone reads, only you write. Blog posts, a changelog, a menu. |
{ read:"audience", create:"users", update:"author", delete:["author","owner"] } | Signed-in visitors add; each edits only their own; you can moderate. Comments, a wall. |
{ read:"author", write:"author" } | Everyone sees and edits only their own rows. Per-person to-dos, drafts. |
{ read:"audience", write:"audience" } | Anyone who can view may write. A classic open guestbook (opt in deliberately). |
admins lets you name extra people who count as owner for moderation:
quick.db.collection("comments", {
read: "audience", create: "users", update: "author",
delete: ["author", "owner"],
admins: ["editor@acme.com"], // can delete anyone's comment
});
A read returns a _mine flag on each record (true when you authored it) so your page can show “edit”/“delete” only where they'll work, without exposing anyone's email.
Server functions
Ship a tiny server-side backend with your page: code that runs off-page, holds secret keys, and makes trusted writes the browser can't forge.
A static page plus quick.db covers a lot, but everything there runs in the browser where anyone can see it. A function adds trusted JavaScript that runs on the server, so it can call a paid API with a secret key, tally a vote nobody can stuff, or validate input before it touches your data.
browser (public) → quick.fn("checkout") → your function (secret env, scoped db) → quick.db
One file is one function
Drop a file in a functions/ folder next to your page and it becomes a server-side endpoint. The filename is the name:
my-app/
index.html # your page (public, served from the edge)
functions/
checkout.js # → quick.fn("checkout")
lib/
stripe.js # NOT a function, a shared module the entrypoints import
Top-level files are entrypoints; anything in a sub-folder is shared code you import. Function source is stored server-side and never shipped to the browser.
quickish.The handler
A function exports a default async handler. Return a Response, or just a plain object (sent as JSON) or a string:
// functions/hello.js
export default async function (req, { identity }) {
const me = identity(); // the signed-in visitor, or null
return { hello: me ? me.email : "world" };
}
The handler receives the request and a context object with identity(), env (your secrets), db (a trusted scoped quick.db), and fetch (guarded outbound calls). The full contract is in the server functions reference.
Declare what it needs
A function says what it can touch with a statically-declared config that's read at publish time, so it travels with the file (and with a remix):
// functions/checkout.js
export const config = {
env: ["STRIPE_SECRET"], // secret names it may read
db: { orders: "write", prices: "read" }, // collections + access
egress: ["api.stripe.com"], // hosts it may call out to
};
export default async function (req, { env, db, identity }) {
const me = identity();
const order = await db.collection("orders").create({ user: me?.email, items: req.body.items });
return Response.json({ ok: true, id: order.id });
}
Secrets are set out of band (never in your code or repo), encrypted at rest, and injected only at call time. The injected db is the owner's trusted writer: it can write owner-only collections a public visitor can't, but only the collections you named. Outbound fetch is off until you list hosts in egress.
Call it from your page
const res = await quick.fn("checkout", { items: cart });
if (res.ok) showReceipt(res.id);
// GET with no body:
const status = await quick.fn("status", null, { method: "GET" });
Run it on a schedule
A function can also run on its own, on a timer. Add a trigger in your app's settings file, quickish.json, keyed by the function it fires:
// quickish.json
{
"name": "Flipboard",
"timezone": "America/Los_Angeles", // optional default for time-of-day
"triggers": {
"weather": { "type": "timed", "value": "15m" }, // every 15 minutes
"digest": { "type": "timed", "value": "9:00am", "daysOfWeek": ["mon", "wed", "fri"] }
}
}
The schedule lives in the settings file, not the function's config, so it travels with the app (and with a remix). A trigger's value is either a frequency (30s, 5m, 1h, "10 minutes", with a floor of 30 seconds) or a time of day (9:00am, 13:00, 1pm) that runs once a day. Add daysOfWeek to limit which days, or omit it to run every day. A time-of-day trigger resolves in the trigger's own tz, then the app timezone, then your account's timezone. The type is "timed" today; route triggers come later.
A scheduled run executes the same function as the page owner, so identity() is you and owner-only db writes work. It arrives as a POST with a header that marks it a timer run, so you can tell it from a page call:
// functions/weather.js
export default async function (req, { db, fetch }) {
const scheduled = req.headers["x-quickish-trigger"] === "timed";
const r = await fetch("https://api.open-meteo.com/v1/forecast?…");
await db.collection("weather").create({ at: Date.now(), reading: await r.json() });
}
A run that throws, times out, or returns a 5xx counts as a failure; five in a row pauses the schedule until your next publish re-arms it. Running quickish dev fires the same triggers on plain timers, so you can test a schedule offline before you publish.
The flipboard demo uses a scheduled function to refresh its weather every 15 minutes, instead of fetching it on every page load.
Run it from a queue
Set config.queue when a function should process background messages. Quickish starts it when work is ready and handles success, retries, and worker limits. See the quick.queue guide.
Limits
Each invocation runs in an isolated sandbox with comfortable caps, so ordinary API calls and trusted writes fit comfortably while runaway code gets contained.
| Limit | Default |
|---|---|
| Memory | 64 MB heap per invocation |
| Time | 10 s wall-clock ceiling, 5 s CPU deadline |
| Outbound fetches | Up to 25 subrequests per invocation |
| Request body | 1 MB into the function |
| Response body | 4 MB out of the function |
There is also a per-account rate ceiling: 120 calls per minute, 2,000 per hour, and 20,000 per day. A function error surfaces as a warning and never breaks your page deploy.
functions/contact.js that emails me when someone submits the form, with my SendGrid key as a secret”Background work with quick.queue
Put work in a queue now and process it safely when a worker is free.
A queue is useful when a task should not make someone wait. Your page adds a message, then a server function processes it in the background. Common examples include sending email, resizing images, importing files, and calling a slow API.
Send a message
Send any value that JSON can store:
await quick.queue("emails").send({
to: "sam@example.com",
subject: "Welcome!"
});
Process messages with a function
Create a server function and name the queue in its config:
// functions/send-email.js
export const config = { queue: "emails" };
export default async function (message) {
await sendEmail(message);
}
Publish with quickish. There is no queue to create in a dashboard. Quickish starts the queue when the first message arrives and runs the function when work is ready.
The useful defaults
- A message can stay in the queue for 24 hours.
- A failed message is retried up to 3 times.
- One copy of the function runs at a time.
- Retries wait longer after each failure.
Queue delivery happens at least once. A function may receive the same message again after a timeout or restart. Make repeated work safe when possible. For example, store an order ID and check whether it was already completed.
Change lifetime and retries
Pass options when one message needs different rules. Times use milliseconds.
await quick.queue("emails").send(message, {
ttlMs: 2 * 60 * 60 * 1000, // keep for 2 hours
maxRetries: 8
});
ttlMs can be from 1 second to 30 days. maxRetries can be from 0 to 25. A message is removed when its lifetime ends, even if it is waiting for another retry.
Process more at once
Start with one worker. Add more when each message is independent and the service you call can handle the extra traffic.
export const config = {
queue: {
name: "emails",
maxAsyncWorkers: 10,
batchSize: 5
}
};
export default async function (message) {
await sendEmail(message);
}
maxAsyncWorkers is the number of function copies that may run at once. The default is 1 and the maximum is 100. batchSize is the number of messages each worker processes before it stops. The handler still receives one message at a time.
Take one in browser code
Most apps should use a server function. A signed-in browser can also take one message when a person or browser tool needs to do the work:
const job = await quick.queue("imports").take();
if (job) {
try {
await importFile(job.message);
await job.ack();
} catch (error) {
await job.retry(error);
}
}
take() returns null when the queue is empty. Taking a message hides it for 30 seconds. ack() removes it. retry() puts it back after a short wait. If the browser closes, the message becomes available when the lease ends.
const job = await quick.queue("imports").take({
visibilityTimeoutMs: 2 * 60 * 1000
});
Names, limits, and local work
Queue names use lowercase letters, numbers, dots, underscores, and hyphens. They start with a letter and can be up to 63 characters. A message can be up to 256 KB.
Queues belong to one Quickish page. A page cannot read or process another tenant's queue. Browser queue calls require a signed-in visitor. Server functions keep the same secret, database, network, memory, and time limits described in Server functions.
quickish dev includes an in-memory queue and runs queue functions automatically. Use the same code locally, then publish without changing the queue setup. The local queue starts empty each time you restart quickish dev.
Relational data with quick.sql
A real relational database at your page, with tables, joins, and constraints and zero provisioning. It's managed Postgres without the setup.
quick.db (documents) is the default for “just store some JSON.” quick.sql is the upgrade for when you actually have relations: a leaderboard, a kanban, anything that outgrows a document store. You get Postgres directly, behind one tagged-template call. No connection string, no setup.
Query from your page
quick.sql is a tagged template that returns the rows. Interpolated values are always bound parameters ($1, $2, …) and never spliced into the SQL, so a hostile value can never inject:
const top = await quick.sql`
select name, sum(points) as pts
from scores group by name order by pts desc limit 10`;
const mine = await quick.sql`select * from scores where name = ${user}`;
The browser path is read-only: a single select / with statement, automatically row-capped and time-bounded. Reads follow your page's audience, so a private page's data stays private.
Declare your schema
Schema is declared, not improvised, so it's reproducible and travels with the app. Put ordered *.sql files in a sql/ folder next to your page; they're applied at publish, in filename order, exactly once each:
my-app/
index.html
sql/
001_init.sql
002_add_index.sql
-- sql/001_init.sql
create table if not exists scores (
id serial primary key,
name text not null,
points int not null default 0,
created_at timestamptz not null default now()
);
Publish and the migrations run automatically. A re-publish is a no-op unless you add a new file; applied migrations are tracked, so existing ones never re-run.
Writes go through a function
Today, runtime writes come from a server function, your trusted server-side code, so a public visitor can read the leaderboard but only your logic can change it. (Schema and seed data come from your migrations at deploy.) Direct browser insert/update is intentionally not allowed.
How it's isolated
Each site gets its own Postgres schema, owned by a dedicated least-privilege role. A connection for your site can only ever reach your schema, so cross-site access is impossible by construction. It's plain Postgres: use the types, constraints, and functions you already know, and round-trip your data with quickish export.
quick.db or quick.sql?
| Reach for | When |
|---|---|
quick.db | You just want to store and read some JSON, with live updates and per-page permissions, with no schema to think about. |
quick.sql | You have real relations: joins, aggregates, constraints, a leaderboard or kanban that needs group by and order by. |
They're independent: use either, or both, on the same page.
Realtime & casting
Push live updates between everyone on a page, and turn any page into a screen you control from your phone, both with nothing to provision.
Live updates with quick.realtime
Open a named channel; everyone on the page who joins it sees what you publish, instantly. It's a thin pub/sub bus over websockets:
const room = quick.realtime("lobby");
room.on(msg => console.log("got", msg));
room.publish({ type: "cheer", from: "Tony" });
If you're storing the data anyway, quick.db's subscribe() already pushes create/update/delete events for a collection. Use quick.realtime when you want ephemeral signals (presence, cursors, a buzzer) that don't need to be saved. The channel is scoped to your page, and inherits the page's visibility: a private page's channel is gated at the edge exactly like the page.
Cast to a screen
Put a Quickish page on a TV, projector, or smart display and push content to it from your phone: a link, an announcement, or an image. One call turns the page into a cast target:
<script src="/quick.js"></script>
<script>quick.cast()</script>
Open that page on the big screen and it shows a short pairing code and a link. On your phone, open the same page, enter the code, and you get a remote that pushes to the screen in real time. quick.cast() figures out which side it's on: it's the screen by default, and becomes the remote when the URL carries a code (#qkcast=CODE), so the same page is both.
Because it's built on the realtime bus, casting inherits the page's visibility: a public page is an open display (anyone with the on-screen code can push), while a private/workspace page only lets people the page is shared with connect at all. Want a custom screen or remote? Drive it directly with quick.cast.receive() and quick.cast.connect(). See the quick.cast reference.
Custom domains
Serve a Quickish page at your own address, like docs.acme.com instead of acme.quickish.space/docs, with an automatic HTTPS certificate.
A custom domain points your own host at a page you've published. Quickish gets a TLS certificate for it from Let's Encrypt on demand and serves your page there, applying the same access rules as the Quickish URL. Everything keeps working: quick.db, quick.fn, quick.sql, realtime.
Two records, one verification
Binding a domain returns the exact DNS to add: point the host at us, and prove you own it:
| Record | Host | Value |
|---|---|---|
| CNAME (sub-domain) | docs.acme.com | quickish.website |
| TXT (ownership) | _quickish-challenge.docs.acme.com | quickish-domain-verify=<token> |
A root/apex domain (acme.com) can't be a CNAME, so it uses an A record to the Quickish IP instead, and the bind response tells you which. Don't proxy the record through a CDN (set Cloudflare to “DNS only / grey-cloud”) so the certificate challenge reaches us.
The flow
- Bind the host to one of your pages. You get back a verification token and the two records above.
- Add the records at your DNS provider: the CNAME (or A) and the TXT.
- Verify. We check the TXT over public DNS; once it's there, the domain goes live and the certificate is issued on the first request. DNS can take a few minutes to propagate.
POST /_cli/domains then POST /_cli/domains/verify, both owner/editor-gated). A polished quickish domains command and a dashboard panel are on the way.Good to know
- A page can carry several custom domains; one domain belongs to a single Quickish account.
- Managing a page's domains needs the same permission as editing the page (owner, or an editor).
- The
.quickish.site,.quickish.space, and.quickish.websiteURLs keep working alongside your custom domain. - Removing a domain stops serving it immediately.
Develop locally
Two commands let you build on your own machine and still use everything Quickish gives a page. quickish dev runs the whole backend locally. quickish --localish puts the site running on your laptop on a real, shareable URL.
Run Quickish on your machine
Run quickish dev in your page's folder and it serves the page locally with the full quick.* surface running for real, just on your machine instead of in the cloud:
$ quickish dev
→ your page on http://localhost:4321 with a live backend
Everything works the same as it will once you publish, with no keys and no round-trip to a server:
- quick.db, quick.sql, quick.realtime, and quick.files all run against local storage.
- Server functions run locally, and their scheduled triggers fire on plain timers so you can test a schedule offline.
- You can sign in to test how the page behaves for a real visitor with quick.identity().
- A dev toolbar in the corner shows what is happening as you go.
If your page is a framework app (Vite, Next, Astro), quickish dev notices, starts it for you, and keeps hot reload working, so you edit source and see changes instantly with the backend attached.
Share your localhost New
Sometimes you want someone else, a teammate, a client, or your own coding assistant, to see the site running on your laptop right now, before it is published anywhere. quickish --localish opens a secure tunnel and gives your local site a public Quickish URL:
$ quickish --localish
→ https://you--myapp.qksh.dev (points at your laptop, live)
It finds the dev server you already have running and puts it on the web. The link stays put while the command runs, so you can hand it out and keep pushing changes behind it.
A shorter, nicer name
Every tunnel gets a stable address like you--myapp.qksh.dev for free. On a paid plan you can claim a short vanity name and reuse it:
$ quickish --localish --claim myapp
→ https://myapp.qksh.dev
Claim and release names from your dashboard too. A name belongs to your account and can be reused across sessions; drop it when you are done and it goes back in the pool.
Who can reach it
A tunnel follows sign-in the same way your pages do, so a private project stays behind a sign-in gate rather than open to the whole internet. For a tool or a teammate who should not have to sign in, hand out the agent link from the dashboard's Share dialog, which grants access without a login. Run it detached with --background, check on it with --status, and take it down with --stop.
Workspace accounts & URLs
Sign in with a Google Workspace email and your whole company shares one Quickish space, and everyone publishes to their own page under it.
Your URL on a work account
On a workspace account you publish to your own page under the company space, keyed by your email handle:
jane@acme.com → acme.quickish.space/jane/
A plain quickish with no page name lands there, so you never overwrite a teammate. Add sub-pages under your handle by naming them: quickish ./deck talk → acme.quickish.space/jane/talk/.
<company>.quickish.space/ is the company homepage. It is reserved for the workspace admin (whoever verified the domain) on the Workspace Unlimited plan; until it is claimed, members see a placeholder there.Visibility on a work account
The default when you don't choose is company-visible: anyone signed in at your domain can see it. You can still make any page private, invite-only, or fully public per page. The same edit policies apply, so a teammate can be added as an editor without being able to take a page over.
What's shared and what isn't
Everyone in the workspace shares the space and the live org directory, but each person's pages and their data are their own. Reaching another page's collection is a deliberate, paid-plan action; same-page access is always free.
Org directory
Every workspace gets a live, read-only directory of its sites built in, with no setup and always current.
quick.org.sites() (the same data as quick.db.collection("org_sites").list()) returns the org's visible sites with hit counts, kept current as teammates publish. That means a company homepage or directory page never needs updating by hand:
const sites = await quick.org.sites();
// [{ id, name, url, owner, visits, visibility, isHome, updated }, ...]
document.querySelector("#dir").innerHTML = sites
.map(s => `<a href="${s.url}">${s.name}: ${s.visits} views</a>`)
.join("");
Members see internal + public sites; the public sees only public ones. Full field list in the quick.org reference.
Plans & seats
Free hosts a single live page. Paid keeps every page you publish live at once.
| Plan | Price | Storage / Bandwidth | Includes |
|---|---|---|---|
| Free | $0 | 1 GB / 10 GB per mo | One live page, public or private, all three publish methods. |
| Personal Unlimited | $7.99/mo | 5 GB / 50 GB per mo | Unlimited live pages & sub-pages. |
| Workspace Unlimited | $99/mo | 100 GB / 1 TB per mo | Unlimited live pages for the whole company. 100 publishers (unlimited viewers); add more for $3/mo or $24/yr each. |
On free, across sites your latest published page stays live; the rest stay saved and come back the moment you upgrade. Paid serves them all at once.
Publishers & seats
A publisher is anyone in your org who owns a live site. The workspace plan includes 100 publishers and unlimited viewers, so most teams never hit it. Anyone signed in at your domain can publish up to that limit.
Past 100, the workspace admin (whoever verified the domain) decides in the dashboard: either free up a seat, or turn on per-publisher billing at $3/mo ($24/yr) for each publisher beyond 100, optionally capping the total. Until then, publishing is blocked at 100.
The quick global
Add one script tag and a quick object appears on the page. Everything below hangs off it, with no imports, keys, or configuration.
<script src="/quick.js"></script>
| API | What it does |
|---|---|
| quick.db | Document store: collections of JSON with filter/sort, permissions, and live subscriptions. |
| quick.sql | Read-only relational SQL (Postgres) as a tagged template. |
| quick.queue | Durable background messages with retries, lifetimes, and automatic function workers. |
| quick.realtime | A pub/sub channel over websockets for ephemeral live signals. |
| quick.cast | Turn the page into a screen you push to from your phone. |
| quick.files | Upload a file and get back a URL. |
| quick.identity | The signed-in visitor, or null. |
| quick.fn | Call a server-side function by name. |
| quick.org | Workspace-only: the org's live site directory. |
Almost everything returns a Promise, so use await. All calls are scoped to the page they run on and run through the same access rules as the page itself. Trusted, off-page logic lives in server functions; reaching a page's data over plain HTTP is the HTTP API.
quick.db
A document store. You work with named collections of JSON documents; each document gets an id and a created_at automatically.
quick.db.collection(name, options?)
Returns a handle to the collection. options is only needed to declare a permission policy or to mark the collection as remix seed data, and only the owner page's declaration counts.
| Option | Type | Meaning |
|---|---|---|
read | principal | principal[] | Who can read. Default "audience". |
create / update / delete | principal | principal[] | Who can do each write op. Default "owner". |
write | principal | principal[] | Shorthand for create + update + delete. |
admins | string[] | Extra emails that count as owner. |
seed | boolean | true ships this collection's docs into a remix (max 500). |
Principals: owner · audience · author · users · workspace · public. See Permissions.
.list(query?)
Returns an array of { id, ...fields }. With no query it returns the collection (capped at 500 rows). The query filters server-side:
| Field | Example | Meaning |
|---|---|---|
where | { done: false } | Top-level field equality. Operators: { votes: { gte: 3 } } with gt, gte, lt, lte, ne, in. |
order | "-created_at" | Sort field; prefix - for descending. Accepts an array for multi-sort. |
limit | 20 | Max rows (server cap 500). |
offset | 40 | Skip rows, for paging. |
await tasks.list({ where: { done: false }, order: "-created_at", limit: 20 });
.create(doc) · .update(id, patch) · .remove(id)
create returns the stored document (with its new id and created_at). update merges the patch into the existing document rather than replacing it. All three are subject to the collection's write policy.
const t = await tasks.create({ title: "Ship it", done: false });
await tasks.update(t.id, { done: true }); // merges, title is untouched
await tasks.remove(t.id);
.subscribe(handlers)
Opens a websocket and calls your handlers as documents change. onCreate and onUpdate receive the document; onDelete receives the id. The return value is a function that closes the subscription.
const off = tasks.subscribe({
onCreate: t => addRow(t),
onUpdate: t => patchRow(t),
onDelete: id => dropRow(id),
});
// later: off();
Reads serve a pre-baked snapshot on first paint (so the page isn't blank), then go live, so your first list() is instant and subsequent ones fetch fresh.
quick.queue
Send durable messages, process them with server functions, or lease one in signed-in browser code.
quick.queue(name)
Returns a handle for a queue owned by the current page.
.send(message, options?)
Adds one message. The default lifetime is 24 hours and the default retry limit is 3. Messages can be up to 256 KB.
.take(options?)
Leases the next ready message for a signed-in browser. Returns null when none is ready. The default lease is 30 seconds.
The Job object
| Field or method | Meaning |
|---|---|
message | The JSON value sent by the producer. |
id / attempts | The message ID and current delivery attempt. |
expiresAt / leaseUntil | When the message expires and when this lease ends. |
ack() | Marks the work complete and removes the message. |
retry(error?) | Records a failure and makes the message available again after a wait. |
For automatic processing, export config.queue from a server function. See the quick.queue guide.
quick.crypto
Zero-knowledge encrypted data. Keys are derived and held in the browser; Quickish stores only ciphertext and wrapped keys. See the Encrypted data guide for the tour.
quick.crypto.create(name, passphrase)
Creates a new vault (a key) sealed by passphrase (minimum 12 characters) and returns a live Vault. The passphrase never leaves the browser.
quick.crypto.unlock(name, passphrase)
Opens a vault you own on this device. Rejects if the passphrase is wrong.
.suggest() · .vaults()
suggest returns a strong random passphrase (about 120 bits). vaults lists the vaults you own.
Sharing
enroll publishes your sharing identity once (an RSA key whose private half is wrapped under your passphrase). identity unlocks that private key so you can open vaults shared with you; shared lists them; unlockShared opens one.
quick.crypto.requestAccess(name, options?)
Asks online key-holders for the key over the realtime bus with no prior setup: a throwaway key is minted for the request, and the wrapped key is relayed back. options.onFingerprint(fp) gives you a short code to confirm with the grantor out-of-band. Resolves to an in-session vault.
Passkey unlock experimental
Create or unlock a vault with a WebAuthn passkey instead of a passphrase, where the authenticator supports the PRF extension. Support varies, so keep the passphrase path as a fallback.
quick.crypto.recoverWithEscrow(name, escrowPassphrase)
Break-glass recovery using the vault’s escrow passphrase. The returned vault has no passphrase wrap yet; call vault.rekey() to set one.
The Vault object
What create, unlock, unlockShared, requestAccess, and recoverWithEscrow return:
| Method | Meaning |
|---|---|
db(name) | A sealed collection: .create, .list, .update, .remove, encrypted and decrypted locally. No where / order. |
share(email) | Wrap the key to an enrolled member and store the grant. |
grants() · revoke(email) | List who has access; drop a grant (rotate to fully cut off). |
rotate({ remove?, escrowPassphrase? }) | New key, re-encrypt every sealed doc, re-share with whoever remains. |
serveGrants(approve) | Approve live requestAccess calls; approve({ email, fingerprint }) returns a boolean. Returns a stop function. |
setEscrow(passphrase) · removeEscrow() | Enable or disable break-glass recovery. |
rekey(newPassphrase) | Change the vault’s passphrase (the underlying key is unchanged). |
Under the hood: a per-vault AES-256-GCM key, wrapped to each member with RSA-OAEP, derived from passphrases with PBKDF2, all via WebCrypto in the browser. The server stores only ciphertext and wrapped keys.
quick.sql
A tagged template for running read-only SQL against your page's own Postgres schema. Resolves to the array of result rows.
Write SQL inside the template literal. Any ${value} you interpolate becomes a bound parameter ($1, $2, …) and never spliced into the SQL text, so a hostile value can't inject. The call resolves to an array of row objects, and rejects (throws) if the query errors.
const rows = await quick.sql`
select name, sum(points) as pts
from scores group by name
order by pts desc limit 10`;
const mine = await quick.sql`select * from scores where name = ${user}`;
| Property | Detail |
|---|---|
| Statements | A single select or with from the browser. Read-only. |
| Limits | Automatically row-capped and time-bounded. |
| Visibility | Follows the page's audience, so a private page's data stays private. |
| Writes / DDL | Schema and seeds via sql/NNN_*.sql migrations; runtime writes via a function. |
Each site has its own isolated Postgres schema owned by a least-privilege role, so cross-site access is impossible by construction. See the quick.sql guide for schema migrations and the db-vs-sql decision.
quick.realtime
A lightweight pub/sub channel over websockets, scoped to your page. Use it for ephemeral signals you don't need to store.
Join a named channel and you get two methods: on(cb) registers a callback that fires with each published message (already parsed from JSON), and publish(data) broadcasts data to everyone else on the channel.
const room = quick.realtime("lobby");
room.on(msg => {
if (msg.type === "cheer") confetti(msg.from);
});
room.publish({ type: "cheer", from: "Tony" });
| Method | Detail |
|---|---|
on(cb) | Calls cb(message) for each message published to the channel. |
publish(data) | Sends data (any JSON-serializable value) to the channel. |
The channel is scoped to (your page, this channel name) and inherits the page's visibility, so a private page's channel is gated at the edge exactly like the page. For persisted changes, use quick.db's subscribe() instead; for a screen-and-remote experience, use quick.cast (built on this bus).
quick.cast
Turn the page into a cast target (a screen pushed to from a phone), or, when the URL carries a pairing code, into the remote. Built entirely on the realtime bus.
quick.cast(options?)
The all-in-one entry point. It reads the URL: with no pairing code it sets the page up as the screen (returns a Screen); when the URL has #qkcast=CODE it becomes the remote (returns a Remote). The same page is both.
<script>quick.cast()</script>
quick.cast.receive(options?)
Force the page into screen mode. Shows a standby card with a pairing code and link until a remote pushes content.
| Option | Meaning |
|---|---|
title | Heading shown on the standby card. Defaults to the page title. |
code | Use a fixed pairing code instead of a random one. |
qr(link) | Return an image URL to show a scannable QR. Opt-in, so a private URL is never sent to a third party unless you choose one. |
render(msg, mount) | Render pushes yourself instead of the built-in viewers. mount is the full-screen stage element. |
const screen = quick.cast.receive({
title: "Lobby display",
render(msg, mount) { // msg = { type, ... } from a remote
if (msg.type === "text") mount.innerHTML = `<h1>${msg.text}</h1>`;
},
});
quick.cast.connect(code, options?)
Force the page into remote mode for a given code (defaults to the code in the URL). Renders a phone-friendly panel, and gives you send(msg) to push your own messages and stop() to disconnect.
const remote = quick.cast.connect("AB12");
remote.send({ type: "url", url: "https://example.com/slides" });
Built-in message types: url · image · text · clear. Codes use an unambiguous alphabet (no I/L/O/0/1) and live only for the session. Casting inherits the page's visibility.
quick.files
Upload a file from the browser and get back a URL you can store or display.
quick.files.upload(file)
Pass a File (e.g. from an <input type="file">) or a Blob. It's sent as multipart form data and resolves to a record describing the stored file, including its url. Uploads count against your plan's storage.
<input type="file" id="pick">
<script>
pick.onchange = async () => {
const { url } = await quick.files.upload(pick.files[0]);
await quick.db.collection("photos").create({ url });
};
</script>
Stored files are served first-party and follow your page's visibility.
quick.identity
Find out who is viewing the page: the signed-in visitor, or null if they're anonymous.
quick.identity()
Resolves to the current visitor's identity, or null when no one is signed in. The same identity a server function sees via its identity() helper.
const me = await quick.identity();
if (me) greet(me.email);
else showSignInButton();
| Field | Meaning |
|---|---|
email | The signed-in person's email. |
tenant | Their workspace/tenant (for company accounts). |
site | The site the identity is scoped to. |
Identity is a fact about who's viewing; it doesn't grant access by itself. What a visitor can read or write is decided by your collection policies.
quick.fn
Call one of your page's server functions by name and get its reply back.
quick.fn(name, body?, options?)
Invokes functions/<name>.js. By default it POSTs body as JSON; the result is parsed as JSON when the function replies with JSON, otherwise returned as text.
| Argument | Detail |
|---|---|
name | The function's filename without .js (route-safe: a-z, 0-9, -). |
body | Sent as a JSON request body. Omit or pass null for none. |
options.method | Override the HTTP method, e.g. "GET" (no body sent). |
const res = await quick.fn("checkout", { items: cart });
if (res.ok) showReceipt(res.id);
// GET with no body
const status = await quick.fn("status", null, { method: "GET" });
quick.org workspaces
Workspace accounts get a live, read-only directory of the org's sites with hit counts, with no setup and always current.
quick.org.sites()
Returns the org's visible sites. Identical to quick.db.collection("org_sites").list(). Members see internal + public sites; the public sees only public ones.
| Field | Meaning |
|---|---|
id | Site identifier. |
name | Display name. |
url | Live URL. |
owner | Who publishes it. |
visits | Live hit count. |
visibility | public / workspace / private. |
isHome | Whether it's the company homepage. |
updated | Last publish time. |
const sites = await quick.org.sites();
sites.sort((a, b) => b.visits - a.visits);
See the org directory guide for a full directory page example.
Server functions
The contract for a functions/<name>.js file: what your handler receives, what it returns, and how it declares what it's allowed to touch.
The handler
Export a default async function. Return a Response, or just a plain object (sent as JSON) or a string.
req
| Field | Meaning |
|---|---|
method | HTTP method. |
path | Request path. |
query | Parsed query-string object. |
headers | Request headers. |
body | Parsed JSON the page sent, or null for GET. |
ctx
| Field | Meaning |
|---|---|
identity() | The signed-in visitor as { email, tenant, site }, or null. Same identity the page sees. |
env | Your declared secret values, the real secrets the browser never sees. |
db | A trusted, pre-scoped quick.db (same methods) that can write the owner-only collections you declared. |
fetch | A guarded outbound fetch, off unless you allow hosts in config.egress. |
Return helpers mirror the web platform: Response.json(obj), Response.redirect(url), or new Response(body, { status, headers }).
export const config
A statically-declared object, read at publish time, that travels with the file. It's how a function says what it's allowed to touch.
| Key | Type | Meaning |
|---|---|---|
env | string[] | Secret names it may read (A-Z, 0-9, _). Values are set out of band. |
db | { collection: "read" | "write" } | Collections it may touch and at what level ("write" implies read). |
egress | string[] | Hosts ctx.fetch may call (["api.stripe.com", "*.openai.com"], or ["*"]). |
queue | string | object | A queue name, or { name, maxAsyncWorkers, batchSize }, for automatic background processing. |
export const config = {
env: ["STRIPE_SECRET"],
db: { orders: "write", prices: "read" },
egress: ["api.stripe.com"],
};
export default async function (req, { env, db, identity }) {
const me = identity();
const order = await db.collection("orders").create({ user: me?.email, items: req.body.items });
return Response.json({ ok: true, id: order.id });
}
Limits
Per invocation: 64 MB memory, 10 s wall-clock, up to 25 outbound calls, 1 MB request / 4 MB response. Per account: 120/min, 2k/hour, 20k/day. A function error surfaces as a warning and never breaks your page deploy. Call functions from the page with quick.fn; the full walk-through is the Server functions guide.
HTTP API & tokens
Reach a page's data and publishing from outside the browser, whether a script, a CI job, or your own agent, with a scoped token.
Data tokens: read & write a page's collections
In your dashboard under Settings → Data tokens, mint a token scoped to one page (read/write or read-only). It goes through the exact same access rules as the in-page SDK and can only ever narrow access, never widen it. Revoke it anytime.
# list documents in a collection
curl -H "Authorization: Bearer $QK_DATA_TOKEN" \
https://api.quickish.website/_data/db/tasks
# create one
curl -X POST -H "Authorization: Bearer $QK_DATA_TOKEN" \
-H "Content-Type: application/json" -d '{"title":"Ship it"}' \
https://api.quickish.website/_data/db/tasks
The same query string as the SDK works here: ?where=<json>&order=-created_at&limit=20. With the Claude connector you don't even need a token; Claude reads and writes through built-in tools.
Publish tokens: put a page online
Grab a publish token in your dashboard under Settings → Publish with AI, then POST a self-contained page:
curl -X POST https://api.quickish.website/_assistant/publish \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "html": "<!doctype html><h1>hi</h1>", "share": "public" }'
Returns { "url": "..." }. Optional fields: page (named sub-page), share (private / public / workspace), invites (emails).
CLI-authenticated endpoints
Some operations are gated to a signed-in CLI session (owner/editor only) while their polished commands land: setting function secrets (POST /_cli/fn-secrets) and binding custom domains (POST /_cli/domains, /_cli/domains/verify).