host0
Manage apps your agent published to host0: sharing, versions, analytics and shared data. Remote server at https://mcp.host0.ai/mcp (OAuth). It doesn't deploy; publishing is by the host0 skill or CLI.
Hosted MCP Server
npx add-mcp 'https://mcp.host0.ai/mcp'Installs into Claude Code, Codex, Cursor and more
Documentation
Documentation
host0 documentation
Everything an agent (or a human) needs to publish an app to host0: get an API key with device login, upload files with the two-phase protocol, and control who can view the result. Base URL for every example: https://host0.ai. The machine-readable index lives at GET /api/v1.
Overview
What host0 is
host0 hosts whatever your agent created — HTML, sites, apps with a shared database, Markdown, PDFs — at a link you can share. You bring any coding agent — Claude, Cursor, Codex, and others — and host0 owns the moment after the code exists: a live URL, shareable like a doc.
The core primitive is an app: a named container for deployments. Publish a directory of static files and the app goes live at https://{slug}.host0.app/. Re-publishing creates a new deployment and flips it live atomically — the old version serves until the new one is complete. Every publish that goes live is a numbered version, and you can put any recent one back in one step.
Every app is private by default: only you (and people you invite by email) can open it; visitors sign in to view, and revoking an invite kills their access immediately. A new app gets a random URL (https://k3x9q2m7ta.host0.app/); rename it — or its display name — from the dashboard (the URL is on the app's Settings tab), the API, or the MCP rename_app tool. Two opt-in alternatives:
- Anyone with the link (
link) — anyone who has the URL can view it, no sign-in. The app is unlisted and never indexed, but the URL is not secret. This is what to use for "a link anyone can open". - Public on the web (
public) — anyone can view it, and search engines may index it. Only for things you want found in search: a portfolio, a landing page, docs. Never for anything sensitive.
host0 serves static sites today — HTML, CSS, JS, images, fonts, assets — plus shared data: an app can declare record collections and read and write them from the page, so several people can work in the same tickets, todos or sign-ups. Server code of your own is on the roadmap.
On the free plan, every HTML page an app serves gets a small Hosted on host0 badge. It sits in the bottom-right corner and moves by itself when one of your page's own buttons or links would be under it: to the first free corner (bottom-left, top-right, top-left), or to a small icon bottom-right if all four are taken. To choose its corner yourself, add <meta name="host0-badge-position" content="bottom-left"> to the page (bottom-right, bottom-left, top-right or top-left; it then stays there). The moving is done by a same-origin script (/.h0/b.js): a Content-Security-Policy that blocks 'self' scripts leaves the badge bottom-right.
A PDF, image, video, audio or text file (.txt, .csv, .md, .json) opened directly in a browser tab opens in a host0 file viewer instead: the file fills the window, with a slim bar under it showing the file name, a Download link and Hosted on host0 — the bar never covers the file. The raw file is still served wherever it's used as a file: <img> and <video> tags in your pages, embeds, fetch(), curl, link previews and search engines. Add ?download to any file URL to download the original. Other files (zip, fonts, JS, CSS…) are served as they are. An HTML page that declares no og:image or twitter:image gets host0's card as its link preview image; add your own tag and yours is used instead. Pro removes the badge and the viewer: every file is served as it is. A Pro app can switch the badge back on in its Settings.
Analytics
Every app counts its visits, without cookies: host0 adds a tiny same-origin script (/.h0/a.js) to each HTML page, and opened PDFs are counted on the server. Nothing to set up, and nothing to remove — don't delete the script. If you set a Content-Security-Policy, allow 'self' in script-src and connect-src.
-
Who's counted. On a private app, only people who can open it (the owner and the people it's shared with). Bots and crawlers are ignored.
-
How visitors are counted. A visitor is counted once per day, with a hash of the app, the IP address and the browser that changes every day — so nobody can be followed across days or apps. IP addresses aren't stored.
-
What you see. Visitors and page views over time, against the period before, plus the top pages, referrers, countries, devices and browsers. Click any row in the app's Analytics tab to filter the whole tab to it (filters combine); the url keeps the filters, so a filtered view can be shared.
-
Where. The dashboard's Analytics tab,
GET /api/v1/apps/{app}/analytics(period24h,7d,30d,90dor365d, plus the same filters), or the MCPget_analyticstool. -
How long. Page views are kept for 13 months.
-
On which plan. Pro shows all of this for up to 90 days (
24hto90d). On the free plan, host0 still counts every visit, but the tab shows only the visits this month, and the API answers 200 withlocked: trueandvisits_this_month— upgrading shows the history. A period past your plan'slimits.account.analytics_daysis a 403analytics_period_not_in_plan.
Versions
Every publish that goes live is a version, numbered per app: v1, v2, v3… Failed or unfinished uploads never use up a number.
- Notes. Give a version a note of what changed —
publish.sh --message "Added a due date column"(up to 200 characters) — or add and edit it later on the app's Versions tab or withPATCH /api/v1/apps/{app}/versions/{n}. - From your agent. The app's Versions tab has a one-line prompt —
Publish my changes to host0 app <slug>— to paste after changing the app in any agent with the host0 skill, so the changes go live as the next version rather than a new app. An agent without the files (a new chat) pulls the live version first:publish.sh --pull --slug <slug>, orGET /api/v1/apps/{app}/files. - From the dashboard. Drop a file, folder or zip on the app's Versions tab (Upload a new version) and it goes live as the next version — same URL, same access, shared data kept. It replaces every file: before you publish, it lists what's new, replaced and removed against the live version. If the drop has no
h0.jsonand the live version does, the live one is kept. Files identical to the live version are refused (409 no_changes), so nothing is added. - Restore. Pick a version on the Versions tab, call
POST /api/v1/apps/{app}/versions/{n}/restore, or use the MCPrestore_versiontool. The old files go live again for everyone, as a new version ("v9, restored from v3"), so history only moves forward and you can undo a restore by restoring the version before it. Restoring the version that's already live changes nothing. - Shared data stays. A restore never changes records. The restored version brings back the collections it ran with: a collection it didn't have is hidden, not deleted — it's still in the export, and restoring the newer version brings it back. A version from before November 5, 2026 with no
h0.jsonkeeps the collections that are live. - How long. Pro keeps versions superseded in the last 30 days, plus the live one; the free plan keeps only the live version (restoring an earlier version there is a 403 versions_not_in_plan). Older ones are deleted automatically. Your number is
limits.account.version_retention_daysinGET /api/v1(0 = only the live version). Kept versions don't count toward your storage quota — only pending uploads and each app's live version do. - No previews. An older version isn't served on its own URL; restore it to see it, then restore the newer one if you change your mind.
Quickstart
Deploy with the skill, manage with MCP
A. Deploy: the host0 skill
Deploying goes through the host0 skill — an instruction file plus one Bash script (requires curl and jq) — in any agent that can run it: claude.ai, Claude Code, Cursor, Codex, and others. The paths below are where Claude Code keeps skills; other agents keep them elsewhere, so use whichever directory yours reads:
mkdir -p ~/.claude/skills/host0/scripts
curl -sS https://host0.ai/skill.md -o ~/.claude/skills/host0/SKILL.md
curl -sS https://host0.ai/publish.sh -o ~/.claude/skills/host0/scripts/publish.sh
chmod +x ~/.claude/skills/host0/scripts/publish.sh
Sign in with device login (see API keys) — or just publish: with no key the script starts the sign-in itself. Then publish any directory with an index.html at its root:
# first publish — creates the app
~/.claude/skills/host0/scripts/publish.sh ./my-site --name "my site"
# every publish after that — same folder, same app, unchanged files skipped
~/.claude/skills/host0/scripts/publish.sh ./my-site
# → https://k3x9q2m7ta.host0.app/
Where Node 18.18 or newer is available, the skill runs the same deploy through the host0 CLI instead — npx -y @host0/cli@latest deploy ./my-site — and falls back to publish.sh when there's no Node or npm can't be reached.
B. Manage: the MCP server
Add host0 as a remote MCP server at https://mcp.host0.ai/mcp — it works in claude.ai (Customize → Connectors), Claude Desktop, and Claude Code:
claude mcp add --transport http host0 https://mcp.host0.ai/mcp
Sign in when prompted. The tools manage apps you've already deployed: sharing, visibility, versions, analytics and shared data. They don't deploy: that's the skill's job. The MCP server section has setup for other clients and every tool.
API keys
Device login: approve in your browser, key out
API keys come from a device login — the way gh or the AWS CLI signs in. The agent sends nothing personal: it gets a short code and a link, the user opens the link on host0 in their own browser, signs in (an email code or Google — host0 has no passwords), checks the code matches and approves, and the agent picks up the key. It works from anywhere an agent runs, including cloud sandboxes like claude.ai, because no email or password ever passes through the agent.
With the skill it's two commands (a publish with no key starts the same sign-in on its own). The first prints the link + code and exits 3 — "waiting for the user":
# 1. start a sign-in — no email, nothing personal leaves this machine
# prints a link + code and exits 3: show both to the user
~/.claude/skills/host0/scripts/publish.sh --login
# 2. wait for the approval — saves the key to ~/.host0/credentials (chmod 600)
~/.claude/skills/host0/scripts/publish.sh --login-wait
The same flow over plain REST, for an agent without the script:
# 1. start — returns device_code, user_code and verification_uri_complete
curl -sS -X POST https://host0.ai/api/v1/auth/device
# 2. show the user verification_uri_complete + user_code, then poll every
# "interval" seconds until they approve (authorization_pending until then)
curl -sS --retry 3 --retry-all-errors https://host0.ai/api/v1/auth/device/token \
-H "content-type: application/json" \
-d '{"device_code": "…"}'
# 3. save the returned api_key yourself — don't make the human run this
mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentials
POST /api/v1/auth/device answers {"device_code", "user_code": "WDJB-MJHT", "verification_uri", "verification_uri_complete", "expires_in": 600, "interval": 5}. Poll the token endpoint every interval seconds: authorization_pending until the user approves, slow_down (with a new interval) if you poll faster, access_denied if they deny, expired_token after 10 minutes. Once approved it returns {"api_key": "h0_…", "key_prefix", "email"} — exactly once; only the key's hash is stored, which is why the key goes straight into ~/.host0/credentials, the conventional location every host0 client looks in.
Send the key on every authenticated request as Authorization: Bearer h0_…. The approval page is https://host0.ai/device — a user can also type the code there by hand.
Email code (fallback)
For an agent running on the user's own machine, an emailed 6-digit code works too — never use it from a cloud sandbox, which shouldn't be sending the user's email address anywhere:
# fallback — only for an agent on your own machine, never from a cloud sandbox
# 1. request a 6-digit code — host0 emails it
curl -sS https://host0.ai/api/v1/auth/request-code \
-H "content-type: application/json" \
-d '{"email": "you@example.com"}'
# 2. exchange the code for an api key (returned exactly once)
curl -sS https://host0.ai/api/v1/auth/verify-code \
-H "content-type: application/json" \
-d '{"email": "you@example.com", "code": "123456"}'
# 3. save it yourself — don't make the human run this
mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentials
Verify answers {"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "message": "…"}. Codes expire after an hour; an optional key_name in the verify body labels the key.
Manage keys with GET /api/v1/keys (list — never shows the secret) and DELETE /api/v1/keys/{key_id} (revoke — takes effect immediately). The dashboard's API keys page (/dashboard/keys) lists your keys with a revoke button too. If a key leaks, revoke it and sign in again for a new one.
Publishing
The two-phase protocol
Publishing is a manifest-first, two-phase flow: declare what you're uploading, upload only what the server doesn't already have, then finalize. Nothing is live until finalize succeeds — a half-finished upload never serves.
- 1Create an app (once) —
POST /api/v1/appswith{"name": "my app"}returns theapp_id, randomslug, and URL. - 2Send the manifest —
POST /api/v1/apps/{app}/deploymentswith one entry per file:path,size(bytes),sha256(lowercase hex of the file bytes), and optionalcontent_type. The response listsupload.targets(files to PUT) andupload.skipped— files whose hash matches the live deployment are hash-skipped, so re-publishing a large site with one edited file uploads one file. - 3Upload each target — PUT the raw bytes to each target's
urlwith itsheaders. Use the URL exactly as returned: in production, file targets are signed upload URLs on the hosting domain (/.h0/upload/…) that need no API key, whileh0.jsonuploads to the API (/api/v1/deployments/{id}/files/h0.json) with your key. Send the key only to the API origin. Either way the bytes are checked against the manifest's size and SHA-256. - 4Finalize —
POST /api/v1/deployments/{id}/finalizeflips the deployment live atomically and returns its version number. Until then the previous deployment (or nothing) serves. Check progress any time withGET /api/v1/deployments/{id}.
# 2. manifest → upload targets
RESP=$(curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/deployments \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"files": [{"path": "index.html", "size": 1234, "content_type": "text/html; charset=utf-8", "sha256": "9f86d08…"}]}')
DEPLOYMENT_ID=$(echo "$RESP" | jq -r .deployment_id)
# 3. PUT the bytes to each target's url, with its headers
# (send your API key only when the url is on https://host0.ai)
UPLOAD_URL=$(echo "$RESP" | jq -r '.upload.targets[] | select(.path == "index.html") | .url')
curl -sS -X PUT "$UPLOAD_URL" \
-H "content-type: text/html; charset=utf-8" \
--data-binary @index.html
# 4. flip it live
curl -sS -X POST "https://host0.ai/api/v1/deployments/$DEPLOYMENT_ID/finalize" \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" -d '{}'
# → {"status": "live", "deployment_id": "…", "url": "https://k3x9q2m7ta.host0.app/"}
What to publish
Publish a built static site with index.html at the root of the directory — the directory's contents become the site root. A preflight check runs before any bytes move and rejects the whole deploy (with every problem listed) when it sees:
- No index.html at the root — a site needs an entry point. A deploy of exactly one file (a lone PDF, image or page) is the exception: it serves at
/. - Server code —
.py .php .rb .go .rs .javafiles; host0 doesn't run server code yet. - Unbuilt source —
.ts .tsx .jsx .vue .sveltefiles; build first and publish the output directory (dist/,build/,out/). - Project source —
node_modules/, or manifests likepackage.json,requirements.txt,Gemfile,Cargo.toml,go.mod,Dockerfile,docker-compose*. - Secrets —
.env/.env.*,*.pem,*.key,id_rsa*; never deploy credentials.
Limits
Limits depend on your plan — GET /api/v1 with your API key returns yours under limits (without a key, the free plan's), and GET /api/v1/account adds what you've used this month.
| Limit | Free | Pro |
|---|---|---|
| Max per file | 3 MB (3145728) | 50 MB (52428800) |
| Max per deploy | 100 MB (104857600) | 1 GB (1073741824) |
| Max files per deploy | 1000 | 1000 |
| Stored bytes | 500 MB (524288000) | 10 GB (10737418240) |
| Apps | 5 | 100 |
| Apps public on the web | 3 | 25 |
| Versions kept | Live version only | 30 days |
| Analytics | Visits this month | 90 days |
| Visits a month (soft — never blocks) | 10,000 | 100,000 |
| Hosted on host0 badge | Yes | No |
Stored bytes are pending deployments plus each app's live version — older versions don't count. Every plan also has these caps: 200 deploys per hour, and API requests are throttled to 120 per minute per API key — over the cap you get 429 rate_limited with a retry-after header (seconds). Publishing from the browser (the dashboard and the PDF and HTML tool pages) can also create at most 20 new apps per day per account (browser_app_limit_reached); the API and the skill aren't affected.
Over a cap you get file_too_large or deploy_too_large (413, before any bytes move), storage_quota_exceeded, public_app_limit_reached, app_limit_reached or deploy_rate_limited. Each message says what to change, and when Pro lifts the limit the error also carries upgrade_url.
Access control
Private, link, public
An app uses one visibility at a time:
private(the default) — only the owner and invited emails. Visitors sign in with the invited email address to view.link— "Anyone with the link". Anyone with the URL can view, no sign-in. The app is unlisted and never indexed: every response carriesX-Robots-Tag: noindex, nofollow, noarchive. The URL is not secret, so keep anything sensitive private. Use this when someone wants a link anyone can open.public— "Public on the web". Anyone can view, and search engines may index it: the app's own files are served withX-Robots-Tag: all, while platform pages on its host (sign-in redirects, 404s,/.h0/*) stay noindex. Opt-in, and only for things meant to be found in search — a portfolio, a landing page, docs, a public tool. Capped per plan at 3 on the free plan, 25 on Pro (public_app_limit_reached).
Shared data behaves the same on link and public.
# open an app to anyone with the link — no sign-in, never indexed
# (new apps start private; "public" also lets search engines index it)
curl -sS -X PATCH https://host0.ai/api/v1/apps/k3x9q2m7ta \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"visibility": "link"}'
# invite viewers by email
curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/shares \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"emails": ["sasha@team.com", "mo@team.com"]}'
# remove one
curl -sS -X DELETE https://host0.ai/api/v1/apps/k3x9q2m7ta/shares/sasha@team.com \
-H "authorization: bearer $HOST0_API_KEY"
Revocation is immediate: removing an invite (or making an app private) bumps the app's grant_version, which invalidates every existing viewer session — no waiting for cookies to expire.
Plans
Free and Pro
Every account starts on the free plan — no card. Pro is $9 a month or $90 a year, bought from the dashboard's Billing page or the pricing page. Team — a shared workspace with roles — is coming soon and can't be bought yet. The numbers are in the limits table.
- What Pro adds. No Hosted on host0 badge and no file viewer, files up to 50 MB (3 MB on the free plan), 10 GB of storage, 25 apps public on the web, 30 days of version history and 90 days of analytics.
- Visits are a soft limit. Visits are page views, plus files opened in a tab, across all your apps in a calendar month (UTC). Going over never blocks a visitor: you get one email and a dashboard banner, and on the free plan the badge becomes a slim bar along the bottom until you upgrade or the month ends.
- Viewers are free. People you share an app with, and visitors of a link or public app, never need a plan, and they never count as seats.
- Cancel anytime. Pro stays on until the end of the period you paid for. If a payment fails, Pro stays on for a 7-day grace period while it's retried.
- Downgrading deletes nothing. Apps stay online: public apps beyond the free plan's limit stay public (you can't make new ones public until you're under it), and files already live keep serving — the next deploy has to fit the free plan. Earlier versions outside the free plan's window are removed. Analytics are hidden, not deleted: upgrading again shows the history.
For agents. GET /api/v1/account returns the plan, its limits and this month's usage. A limit error that Pro lifts carries upgrade_url: tell the user which limit they hit, offer the free fix first (compress the file, use link instead of public, delete apps they no longer need), then give them the link. Don't retry in a loop, and never buy anything for the user.
Custom domains
Custom domains
An app at <slug>.host0.app can also answer on a domain you own, like blog.acme.com. Custom domains are on Pro, up to 3 per account. The host0.app address keeps working.
- 1Add it. On the app's Domains tab, or with
POST /api/v1/apps/{app}/domainsand{"hostname":"blog.acme.com"}, or withhost0 domains add <app> blog.acme.com, or the MCPadd_domaintool. You get two DNS records. - 2Set the records at your DNS provider: a
CNAMEfromblog.acme.comtodomains.host0.app, and aTXTat_host0.blog.acme.comthat proves you own it. - 3Check it. The tab keeps checking while it's open, or press Check now, or call
POST /api/v1/apps/{app}/domains/{hostname}/verify. The status goespending→provisioning→active, usually within a few minutes, and the certificate is issued for you. Onlyactivedomains serve.
- Everything works there. Private apps, invites, shared records and analytics all work on your domain. Plain
httpredirects tohttps. - Search engines. An app that's public on the web is indexed on its custom domain, and its
host0.appaddress points there as canonical. With more than one domain, the oldest is the canonical one. - Use a subdomain. A root domain like
acme.comonly works if your DNS provider can flatten aCNAME(often calledALIASorANAME). Wildcards aren't supported. - Removing it. It stops serving straight away. A pending domain that never verifies is cleared after 30 days. Going down to the free plan never takes a domain offline, but you can't add new ones.
Shared data
A database, without a backend
A static page covers one person looking at one thing. Most team tools are several people sharing state — a team todo, a ticket board, an RSVP list. So an app can declare record collections in an h0.json at the root of its deploy, and its frontend reads and writes them at /.h0/data/… on its own origin. No server code, no connection string, no schema to migrate — and because the URLs are relative, no CORS.
Declare the collections
Ship this file with the rest of the site. It's validated at deploy: a bad manifest fails the upload with the exact line to fix, before anything goes live.
{
"records": {
"tickets": {
"fields": {
"title": { "type": "string", "required": true, "maxLength": 200 },
"done": { "type": "boolean", "default": false },
"status": { "type": "string", "enum": ["todo", "doing", "done"], "default": "todo" },
"assignee": { "type": "string" },
"notes": { "type": "string", "maxLength": 5000 }
},
"access": {
"read": "viewers",
"create": "viewers",
"update": "members",
"delete": "members"
}
}
}
}
- Field types —
string,number,boolean,json(an arbitrary object). Constraints:required,default,maxLength,enum. A field host0 doesn't know about is rejected by name. - Schema changes are additive — a new collection or a new optional field applies on the next deploy. Removing a collection stops serving it and keeps its data: hidden, still exportable, never silently destroyed.
- No h0.json, nothing changes — a plain static site deploys exactly as before, and an app that has collections keeps them. An
h0.jsonmust have arecordskey, and it's the only top-level key: anything else is refused withinvalid_manifest.{"records": {}}declares no collections, which is how you turn records off.
A publish without h0.json keeps the app's current collections, so you can redeploy just the changed files. To change them, publish an h0.json with all the collections the app should have. To turn records off, publish {"records": {}}: the collections stop being served and their data is kept, still exportable. Restoring a version brings back the collections that version ran with.
Who can do what
Each action gets an audience. Audiences are evaluated against the app's own visibility and share list — there is no second permission system to keep in sync, so removing someone's invite revokes their writes at the same instant it revokes their viewing.
| Audience | Means |
|---|---|
| viewers | Anyone who can open the app — on a link or public app that includes visitors who aren't signed in |
| members | The owner plus the emails the app is shared with, even when anyone can open the app itself |
| owner | The app owner only — you, not each visitor |
| creator | Each signed-in person, on their own records only — a list returns just the caller's records (the owner's too), so one URL serves everyone their private data |
| ["a@b.com", …] | An explicit list of emails |
By default everyone the app admits can read and create, while update and delete need an invite (members). The owner always passes every rule.
On a link or public app, viewers includes people who aren't signed in — that's the RSVP/poll case, and it's deliberate. For a team tool, set the writes you care about to members and invite the team by email. When a rule needs an identity the caller doesn't have, the API answers 401 sign_in_required with a signin_url — send the visitor there and they land back on the page they were on. Its message is written for the visitor ("Sign in to add tickets."), so show it as it is, next to the link or as its text.
Shared or per-person? Ask first
Decide whose data it is before writing h0.json — and if the user hasn't said, ask them. Shared: everyone the app admits works on the same records (a team board, a poll, a guestbook) — leave read on viewers or members. Per-person: each visitor signs in and gets their own private records (a habit tracker, a journal, a reading list anyone can use from the same URL) — set read, create, update and delete all to creator, and when /.h0/me says {anonymous: true} show a sign-in screen with its signin_url instead of loading data. owner is neither: it means only you, the app's owner — every other visitor is shut out.
The one thing that trips agents up
On a link or public app, call /.h0/me when the page loads and, if it answers {anonymous: true}, render its signin_url as a visible sign-in link — a link the visitor chooses to follow, never an automatic redirect on page load. Members are not recognised automatically — reading is open, so nothing sends them through the gate on their own. Handle 401 on writes too: the body carries signin_url and a message written for the visitor ("Sign in to add tickets.").
- An API key never authenticates a record call.
authorization: bearer h0_…is for/api/v1on the platform origin; identity on the app's own origin is the browser session from/.h0/me. A records call carrying an API key is simply treated as anonymous. - Sharing sends an invitation email with sign-in and sign-up links. It does not create an account: the person signs up or signs in with the invited address. Check invite_delivery: if email fails, access is still granted and the owner can resend or share the sign-in link.
- Request bodies are flat (the declared fields); responses wrap them in
dataalongsideid,created_at,created_by_emailand friends.created_by_emailis on the envelope, not insidedata. On a link or public app it's null for anyone who isn't the owner, an invited member or the record's author, so a signed-in post never shows its author's email to strangers — render a fallback ("someone") or a name field instead. h0.jsonis served as an ordinary static file, so anyone can read your collection names, fields and access rules. That's by design — it holds no secrets. Never put one in it.
The endpoints
All of them live on the app's own origin (https://k3x9q2m7ta.host0.app/), so use relative URLs.
| Method | Path | What it does |
|---|---|---|
| GET | /.h0/data/{collection} | List records, newest first. ?limit=, ?order=asc|desc and ?cursor=. → {records: [...], count, nextCursor} — nextCursor is null on the last page; otherwise pass it as ?cursor= for the next one, with the same order. |
| POST | /.h0/data/{collection} | Create one — body is the record's declared fields. → 201 and the record. With an Idempotency-Key header, a retry returns the original record (200, idempotent-replayed: true) instead of a duplicate. |
| GET | /.h0/data/{collection}/{id} | Read one record. |
| PATCH | /.h0/data/{collection}/{id} | Merge the fields in the body into the record; absent fields are left alone, so two people editing different fields don't clobber each other. |
| DELETE | /.h0/data/{collection}/{id} | Delete one record. → 200 {id, deleted: true} — a JSON body like every other response, never an empty 204. |
| GET | /.h0/me | Who is calling — {anonymous: true, signin_url} or {anonymous: false, user_id, email, owner}. |
Every record reads and writes as one envelope:
{
"id": "9d1f…", // host0 assigns it
"collection": "tickets",
"data": { "title": "Ship it", "done": false }, // your declared fields
"created_by": "…" | null, // null = written by an anonymous visitor
"created_by_email": "ada@team.com" | null, // null unless you're a member or wrote it
"created_at": "2026-08-03T21:00:43Z",
"updated_at": "2026-08-03T21:04:02Z"
}
A form that retries after a network error should send an Idempotency-Key header on POST /.h0/data/{collection}, one value per submit (e.g. crypto.randomUUID(), up to 255 characters): the same key with the same fields returns the original record, for 24 hours, so a retry can't duplicate it. The key is scoped to the app, the collection and the caller; the same key with other fields is 422 idempotency_key_reused, and a deleted record frees its key.
Lists are stable while people add records: a cursor is a position in the order, so paging never repeats or skips a record. Without ?limit= a page holds 200, and at most 1000.
A working team todo
The whole client side. Collaboration in v1 is polling: everyone sees each other's changes within a few seconds.
const api = "/.h0/data/tickets";
// records are untrusted input — they were written by other people.
const esc = (s) => String(s).replace(/[&<>"']/g,
c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
// On a link or public app nothing forces a visitor to sign in — reading is open. So an
// invited teammate arrives anonymous and their first edit would 401 with no
// explanation. Ask who's here on load, and offer the sign-in link when nobody
// is. (On a private app they're already signed in and this just names them.)
let me = { anonymous: true };
async function whoami() {
me = await (await fetch("/.h0/me")).json();
who.innerHTML = me.anonymous
? `<a href="${esc(me.signin_url)}">Sign in to edit</a>` // attributes too, not just text
: `Signed in as ${esc(me.email)}`;
}
// Any records call can answer 401 when the action needs an identity the
// visitor doesn't have. Offer sign-in, don't redirect: load() runs on page
// load, and a write would lose what the visitor typed.
async function send(url, options) {
const res = await fetch(url, options);
if (res.status === 401) {
const { signin_url, message } = await res.json(); // returns to this page
who.innerHTML = `<a href="${esc(signin_url)}">${esc(message)}</a>`; // written for the visitor
return null;
}
const body = await res.json();
if (!res.ok) { show(body.message); return null; } // messages are written to be shown
return body;
}
async function load() {
const page = await send(api);
if (!page) return;
list.innerHTML = page.records.map(r =>
`<li data-id="${esc(r.id)}">
<input type="checkbox" ${r.data.done ? "checked" : ""}>
${esc(r.data.title)}
<small>${esc(r.created_by_email ?? "someone")}</small>
</li>`).join("");
}
async function add(title) {
if (await send(api, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title }), // flat: the declared fields
})) load();
}
async function toggle(id, done) {
if (await send(`${api}/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ done }), // only the field that changed
})) load();
}
// collaboration in v1 is polling — everyone sees each other within a few seconds
whoami();
load();
setInterval(load, 4000);
Records are untrusted input. They were typed by other people — escape them when you put them in the DOM (the example does). The XSS surface is your own page.
LLM calls and your own database
An app can also call an LLM with the visitor's own key, or your own Supabase project with its publishable key and Row Level Security, straight from the browser. It can't keep a secret, so your own key never goes in the bundle. See bring your own key, with a complete chat app.
Limits and getting your data out
Up to 20,000 records per app, 32 KB per record, 20 collections. Anonymous visitors of a link or public app can write 120 times an hour per app. The live numbers are in GET /api/v1 under limits.records.
# the owner's view, ignoring the app's own access rules
curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/records \
-H "authorization: bearer $HOST0_API_KEY"
# full json dump of one collection
curl -sS "https://host0.ai/api/v1/apps/k3x9q2m7ta/records/tickets?format=export" \
-H "authorization: bearer $HOST0_API_KEY" -o tickets.json
# fix one record: send only the fields to change
curl -sS -X PATCH https://host0.ai/api/v1/apps/k3x9q2m7ta/records/tickets/$RECORD_ID \
-H "authorization: bearer $HOST0_API_KEY" -H "content-type: application/json" \
-d '{"done": true}'
# remove one record
curl -sS -X DELETE https://host0.ai/api/v1/apps/k3x9q2m7ta/records/tickets/$RECORD_ID \
-H "authorization: bearer $HOST0_API_KEY"
The Data tab of the app's dashboard page shows the same thing with an export button per collection, and lists each collection's records with Edit and Delete buttons. Editing and deleting as the owner, there or through the API, MCP (update_record, delete_record) or host0 api, skips the collection's access rules and uses the same field checks as the app; who created a record never changes. A collection the live version no longer declares can't be edited, since no field rules are left to check an edit against, but its records can still be exported and deleted. Static files plus a records export means you can always leave whole. See get your data for everything else, and delete your account.
API reference
REST API v1
Base URL https://host0.ai. Authenticated endpoints take Authorization: Bearer h0_…. Request and response bodies are JSON (uploads PUT raw bytes). Every error uses one envelope:
{"error": "<code>", "message": "<human-readable, actionable message>", ...details}
message always says what went wrong and what to change — it's written for agents. Some codes add detail fields, noted below.
Finding your way around
Everything here is also machine-readable, and none of it needs a key:
https://host0.ai/openapi.json: this API as OpenAPI 3.1.https://host0.ai/.well-known/api-catalog: where the APIs and their docs are (RFC 9727). The home page'sLinkheader points at it, too.https://host0.ai/api/v1/plans: the plans and their limits as JSON.https://host0.ai/.well-known/agent-skills/index.json: the skills index, sonpx skills add https://host0.aiinstalls the skill from this site. Older clients read/.well-known/skills/index.json.- Every public page answers as Markdown: send
Accept: text/markdown, or add.mdto its path (/pricing.md, and/index.mdfor the home page).
Error codes
| Code | HTTP | Meaning |
|---|---|---|
| invalid_hostname | 400 | Domains: not a hostname you can use — message says why |
| domain_limit_reached | 403 | Domains: the account holds its plan's limit, 0 on Free — carries plan and upgrade_url |
| domain_not_found | 404 | Domains: this app has no such domain |
| domain_exists | 409 | Domains: this app already has that domain |
| domain_taken | 409 | Domains: another account has verified that hostname |
| domains_unavailable | 503 | Domains: custom domains are off for now — the app's own URL keeps working |
| bad_request | 400 | Malformed body or invalid field (incl. manifest validation and invalid paths) |
| size_mismatch | 400 | Uploaded bytes don't match the manifest's declared size |
| hash_mismatch | 400 | Uploaded bytes don't match the manifest's SHA-256 |
| unauthorized | 401 | Missing or invalid API key |
| invalid_code | 401 | The emailed code didn't verify (codes expire after an hour) |
| invalid_cursor | 400 | Records: cursor isn't a nextCursor this API gave out — pass it as it is, or leave it out for the first page |
| invalid_idempotency_key | 400 | Records: the Idempotency-Key header isn't 1–255 printable characters with no spaces |
| invalid_period | 400 | Analytics: period isn't one of 24h, 7d, 30d, 90d, 365d |
| invalid_filter | 400 | Analytics: a filter (page, referrer, country, device, browser) is malformed — message says which and what it accepts |
| invalid_version | 400 | Versions: the version isn't a version number like 3 (or v3) |
| authorization_pending | 400 | Device login: the user hasn't approved yet — keep polling every {interval} seconds |
| invalid_device_code | 400 | Device login: unknown or already-used device code — start a new login |
| access_denied | 403 | Device login: the user denied it — start a new one only if they ask |
| expired_token | 410 | Device login: the code expired (10 minutes) — start a new login |
| slow_down | 429 | Device login: polling too fast — wait the returned {interval} seconds between polls |
| sign_in_required | 401 | Records: the caller is anonymous and this action needs an identity — message is written for the visitor, show it with a link to signin_url, and retry after they're back; details: {signin_url} |
| forbidden | 403 | Records: signed in, but not in the audience this action allows |
| app_limit_reached | 403 | The account is at its plan's max apps — deploy into an existing app, or delete one you no longer need; details: {max_apps, apps, plan, upgrade_url?} |
| public_app_limit_reached | 403 | The account already has its max apps public on the web (indexable) — keep this one on link (anyone with the URL, not indexed), or set another public app back to link; details: {max_public_apps, public_apps, plan} |
| storage_quota_exceeded | 403 | The deploy would push the account past its stored-bytes quota — delete apps you no longer need, or deploy fewer/smaller files; details: {storage_quota_bytes, stored_bytes, plan} |
| versions_not_in_plan | 403 | Versions: the account's plan keeps only the live version, so an earlier one can't be restored — publish those files again instead; details: {version, retention_days, plan} |
| analytics_period_not_in_plan | 403 | Analytics: the period is longer than the plan's analytics window — pick a shorter one; details: {max_days, periods, plan} |
| record_limit_reached | 403 | The app is at its max records — delete records you no longer need |
| app_not_found | 404 | No app with that id or slug in your account |
| deployment_not_found | 404 | No deployment with that id in your account |
| file_not_in_manifest | 404 | PUT path wasn't declared in the deployment's manifest |
| key_not_found | 404 | No active API key with that id in your account |
| share_not_found | 404 | That email isn't on the app's share list |
| collection_not_found | 404 | Records: the live deploy declares no such collection (editing as the owner: also one it stopped declaring, whose records can still be exported and deleted) |
| record_not_found | 404 | Records: no record with that id in the collection |
| version_not_found | 404 | Versions: the app has no such version — only the versions inside the plan's window are kept (retention_days); GET /api/v1/apps/{app}/versions lists the ones you can restore |
| slug_taken | 409 | Another app already uses that slug, or another account used it in the last 30 days — pick another (check with GET /api/v1/slugs/{slug}) |
| not_pending | 409 | The deployment already finalized or failed — start a new one |
| upload_incomplete | 409 | Finalize before every file uploaded; details: {missing: [paths]} |
| version_not_restorable | 409 | Versions: that version's files are no longer stored, so it can't be restored — publish those files again instead |
| file_too_large | 413 | A file in the manifest is over the plan's per-file cap — compress it, split it, or leave it out; details: {path, size, max_file_bytes, plan} |
| deploy_too_large | 413 | The manifest's files add up to more than the plan's per-deploy cap — leave out what the site doesn't need, or compress it; details: {total_bytes, max_deploy_bytes, plan} |
| too_large | 413 | An upload to the API is bigger than the per-file byte cap. A manifest over an upload cap is refused earlier, at create-deployment, with file_too_large or deploy_too_large |
| record_too_large | 413 | Records: one record exceeds the per-record byte cap |
| preflight_failed | 422 | The deploy breaks the platform contract; details: {problems: [{paths, message}]} |
| invalid_manifest | 422 | h0.json is malformed; details: {problems: [messages]} naming each line to fix |
| invalid_slug | 422 | The slug is malformed or reserved — 1–63 lowercase letters, digits or hyphens, no hyphen at the start or end |
| idempotency_key_reused | 422 | Records: this Idempotency-Key was already used with different fields — send a new key for a new submit |
| invalid_record | 422 | Records: the body doesn't match the declared fields; details: {problems: [messages]}. 400 when an update has no fields to change |
| rate_limited | 429 | Too many requests — code requests/attempts, device-login starts from one network, or over the per-key API throttle; check the retry-after header |
| deploy_rate_limited | 429 | Too many deploys started in the last hour — wait, then deploy again |
| browser_app_limit_reached | 429 | Browser publish only: the account created its max new apps from the browser in the last 24 hours — update an existing app, or wait (check the retry-after header) |
| internal | 500 | Something broke on our side — retry |
plan in a limit error is the account's plan (free or pro). When Pro would lift the limit and the account is on the free plan, the error also carries upgrade_url (the dashboard's Billing page) and the message ends with it. On Pro, the message names the Team plan (coming soon) instead, with no link.
Endpoints
GET/api/v1No auth
The live index: endpoint list, byte/file caps, and the platform contract line. Agents read this before declaring something unsupported. Auth optional: with your API key, limits and plan are your account's; without one, the free plan's. plans lists every plan with its price and caps.
Response
{
"name": "host0",
"docs": "https://host0.ai/docs",
"endpoints": [{"method": "…", "path": "…", "auth": true, "description": "…"}],
"limits": {
"upload": {"max_files": 1000, "max_file_bytes": 3145728, "max_total_bytes": 104857600},
"account": {"max_apps": 5, "max_public_apps": 3, "max_deploys_per_hour": 200, "storage_quota_bytes": 524288000, "max_browser_apps_per_day": 20, "version_retention_days": 0, "analytics_days": 0, "monthly_visits_soft": 10000},
"rate": {"requests_per_minute_per_key": 120}
},
"plan": {"id": "free", "billing_enabled": true, "badge": true, "upgrade_url": "https://host0.ai/dashboard/billing?upgrade=pro&reason=plan"},
"contract": "Supported today: static sites — …"
}
GET/api/v1/plansNo auth
The plans as data: prices, limits and what each one includes. status is live, and pricing_url is the pricing page.
GET/api/v1/accountAuth: bearer key
Your plan, its limits, and what you've used this month — visits (soft, never blocks), stored bytes and public apps. Same facts as the MCP get_account tool. On Pro, plan also carries renews_at (or ends_at once it's cancelled) and, on a failed payment, past_due_since; upgrade_url is there on the free plan only.
Response
{"email": "you@team.com",
"plan": {"id": "free", "billing_enabled": true, "source": null, "status": null, "interval": null, "past_due_since": null},
"limits": {"upload": {…}, "account": {…, "version_retention_days": 0, "analytics_days": 0, "monthly_visits_soft": 10000},
"rate": {"requests_per_minute_per_key": 120}},
"usage": {"visits_this_month": 1240, "monthly_visits_soft": 10000, "over_visit_limit": false,
"stored_bytes": 2097152, "storage_quota_bytes": 524288000, "public_apps": 1, "max_public_apps": 3},
"upgrade_url": "https://host0.ai/dashboard/billing?upgrade=pro&reason=plan",
"billing_url": "https://host0.ai/dashboard/billing"}
Errors: unauthorized
POST/api/v1/auth/deviceNo auth
Starts a device login — the default way to get an API key. No body, nothing personal. Show the user verification_uri_complete and user_code; they approve on host0 in their own browser.
Response
{"device_code": "…", "user_code": "WDJB-MJHT", "verification_uri": "https://host0.ai/device", "verification_uri_complete": "https://host0.ai/device?code=WDJB-MJHT", "expires_in": 600, "interval": 5, "message": "…"}
Errors: rate_limited
POST/api/v1/auth/device/tokenNo auth
Polls a device login every interval seconds. Returns the API key exactly once, after the user approves.
Request body
{"device_code": "…"}
Response
{"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "email": "you@example.com", "message": "Save this key — it won't be shown again."}
Errors: bad_request, authorization_pending, slow_down, access_denied, expired_token, invalid_device_code
POST/api/v1/auth/request-codeNo auth
Fallback for an agent on the user's own machine: emails a 6-digit sign-in code.
Request body
{"email": "you@example.com"}
Response
{"ok": true, "message": "Code sent — check your email (from host0) and paste the 6-digit code."}
Errors: bad_request, rate_limited
POST/api/v1/auth/verify-codeNo auth
Exchanges the emailed code for an API key. The key is returned exactly once; only its hash is stored.
Request body
{"email": "you@example.com", "code": "123456", "key_name": "laptop"} // key_name optional
Response
{"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "message": "Save this key — it won't be shown again."}
Errors: bad_request, invalid_code, rate_limited
POST/api/v1/appsAuth: bearer key
Creates an app — private by default. Returns 201. The URL gets a random slug; name is only the display name. Pass slug to choose the URL up front.
Request body
{"name": "my app", "slug": "my-app"} // slug optional — random when left out
Response
{"app_id": "<uuid>", "slug": "k3x9q2m7ta", "url": "https://k3x9q2m7ta.host0.app/", "visibility": "private"}
Errors: bad_request, unauthorized, invalid_slug, slug_taken
GET/api/v1/appsAuth: bearer key
Lists your apps.
Response
{"apps": [{"app_id": "…", "name": "…", "slug": "…", "url": "…", "visibility": "link",
"live_deployment_id": "<uuid or null>", "live_since": "<iso or null>", "created_at": "<iso>"}]}
Errors: unauthorized
GET/api/v1/apps/{app_id_or_slug}Auth: bearer key
App detail, including the share list and the live version number (live_version, null before the first publish).
Response
{"app_id": "…", "name": "…", "slug": "…", "url": "…", "visibility": "private",
"grant_version": 3, "live_deployment_id": "<uuid or null>", "live_since": "<iso or null>",
"live_version": 9, "owner_email": "you@team.com", "shares": ["sasha@team.com"], "created_at": "<iso>"}
Errors: unauthorized, app_not_found
PATCH/api/v1/apps/{app_id_or_slug}Auth: bearer key
Changes who can view the app, renames its URL, and/or changes its display name (name, 1–100 characters — only the label in the dashboard and API; the URL stays). Send any of the fields. A URL rename moves the live deployment, records and shares to the new URL; for 30 days the old URL redirects (301, path and query kept) to the new one and is reserved for you — nobody else can claim it, and you can take it back; then it's released. A deleted app's URL is reserved for its owner the same way (it just 404s).
Request body
{"visibility": "link", "slug": "team-todo", "name": "Team todo"} // visibility: private | link | public
Response
{"app_id": "…", "name": "Team todo", "slug": "team-todo", "url": "…", "old_slug": "k3x9q2m7ta", "visibility": "link", "grant_version": 4}
Errors: bad_request, unauthorized, app_not_found, invalid_slug, slug_taken, public_app_limit_reached
GET/api/v1/slugs/{slug}Auth: bearer key
Is this slug free for an app URL? Advisory — the rename checks again. code is one of invalid, reserved, taken, yours.
Response
{"slug": "team-todo", "available": false, "reason": "team-todo is already taken — try another.", "code": "taken"}
Errors: unauthorized
DELETE/api/v1/apps/{app_id_or_slug}Auth: bearer key
Deletes an app completely, live or not: its URL stops working (404) and its deployments, shares and shared records are deleted with it; its slug stays reserved for you for 30 days. Cannot be undone — export records first if you need them; to just hide an app, make it private.
Response
{"deleted": "<uuid>", "slug": "k3x9q2m7ta", "was_live": true}
Errors: unauthorized, app_not_found
POST/api/v1/apps/{app_id_or_slug}/sharesAuth: bearer key
Invites members by email and sends each new invitee an email with sign-in and sign-up links. Duplicates are ignored; returns the full share list.
Request body
{"emails": ["sasha@team.com", "mo@team.com"], "resend": false}
Response
{"slug": "…", "shares": ["mo@team.com", "sasha@team.com"], "invite_delivery": [{"email":"sasha@team.com","status":"accepted"},{"email":"mo@team.com","status":"accepted"}]}
An accepted status means the SMTP server accepted the email, rather than guaranteed inbox delivery. Access stays granted if delivery reports failed or rate_limited. Existing members aren't emailed again (already_sent or not_sent); use resend: true to send to existing members, with a 60-second cooldown (cooldown) across the owner's apps. removed means access was revoked during the request and no invitation was sent. Invite up to 10 addresses per request; the default account limit is 500 email attempts per hour. New invitees create an account with the invited address before signing in.
After a first publish of a private app or one with shared data, the skill has your agent ask once who to invite and calls this endpoint with the emails you give it. On an app with an h0.json, everyone invited works on the same records.
Errors: bad_request (details: {invalid: [emails]}), unauthorized, app_not_found, share_not_found (resend to a removed member)
DELETE/api/v1/apps/{app_id_or_slug}/shares/{email}Auth: bearer key
Removes an invited viewer and bumps grant_version so their existing sessions die immediately.
Response
{"removed": "sasha@team.com", "grant_version": 5}
Errors: unauthorized, app_not_found, share_not_found
GET/api/v1/apps/{app_id_or_slug}/analytics?period=7dAuth: bearer key
Visits to the app over a period: 24h (hourly buckets), 7d (the default), 30d, 90d (daily) or 365d (weekly). Buckets are UTC and zero-filled; the last one is still filling. previous is the same-length window just before since. Top lists hold up to 10 rows, by visitors; a referrer key of "" means direct, a country key of "" unknown. Visitors are counted once per day with a hash that changes daily — no cookies, and IP addresses aren't stored.
Optional filters narrow every number to the visits matching all of them (one value each): page (a path, /pricing), referrer (a host, google.com, or (direct)), country (DE, or (unknown)), device (desktop, mobile, tablet) and browser (Chrome). totals, previous and series apply them all; each top list applies all but its own, so countries still lists every country when country=DE is set. bases holds the visitors each list is counted against (its shares' denominator — totals.visitors unless that list's own dimension is filtered), and filters echoes what was applied ("" = direct / unknown). A malformed filter is a 400 invalid_filter.
Response
{"slug": "team-todo", "period": "7d", "filters": {"country": "DE"}, "bucket": "day",
"since": "2026-10-03T00:00:00Z", "until": "2026-10-09T14:05:12.000Z",
"totals": {"visitors": 248, "pageviews": 1032}, "previous": {"visitors": 210, "pageviews": 947},
"series": [{"t": "2026-10-03T00:00:00Z", "visitors": 31, "pageviews": 128}, …],
"pages": [{"key": "/", "visitors": 212, "pageviews": 590}, …],
"referrers": [{"key": "", "visitors": 156, "pageviews": 640}, {"key": "slack.com", "visitors": 71, "pageviews": 260}],
"countries": [{"key": "GB", "visitors": 98, "pageviews": 401}, …],
"devices": [{"key": "desktop", "visitors": 170, "pageviews": 760}, …],
"browsers": [{"key": "Chrome", "visitors": 132, "pageviews": 560}, …],
"bases": {"pages": 248, "referrers": 248, "countries": 1190, "devices": 248, "browsers": 248}}
Errors: unauthorized, app_not_found, invalid_period, invalid_filter, analytics_period_not_in_plan
On a plan without full analytics (the free plan) the answer is still a 200, with locked: true, this month's visits and a message to show the user — host0 keeps counting, so upgrading shows the history:
Response
{"locked": true, "slug": "team-todo", "plan": "free", "month": "2026-10-01",
"visits_this_month": 412,
"message": "team-todo had 412 visits this month. Full analytics … aren't on the Free plan; … Or upgrade to Pro for full analytics: https://host0.ai/dashboard/billing?upgrade=pro&reason=analytics",
"upgrade_url": "https://host0.ai/dashboard/billing?upgrade=pro&reason=analytics"}
GET/api/v1/apps/{app_id_or_slug}/filesAuth: bearer key
The live version's files, so an agent can edit an app it doesn't have locally (publish.sh --pull does this for you). A download_url on this API's origin needs the bearer key; any other is a signed URL valid for an hour, sent without it. GET /api/v1/apps/{app}/files/{path} returns one file's raw bytes.
Response
{"slug": "team-todo", "url": "<url>", "version": 9, "deployment_id": "<uuid>",
"files": [{"path": "index.html", "size": 4210, "sha256": "<hex>",
"content_type": "text/html; charset=utf-8", "download_url": "<url>"}, …]}
Errors: unauthorized, app_not_found, nothing_deployed, file_not_found
GET/api/v1/apps/{app_id_or_slug}/versionsAuth: bearer key
The app's versions, newest first — every publish that went live is one. The live one is kept, plus versions superseded in the last retention_days days (0 = only the live version). client is the agent that published it (the x-host0-client header, or web:dashboard, mcp:<client>); restored_from_version is set when the version is a restore.
Response
{"slug": "team-todo", "live_version": 9, "retention_days": 30,
"versions": [{"version": 9, "deployment_id": "<uuid>", "live": true, "message": null,
"client": "claude-code/2026.10.05", "restored_from_version": 3,
"file_count": 12, "total_bytes": 48213, "created_at": "<iso>"}, …]}
Errors: unauthorized, app_not_found
POST/api/v1/apps/{app_id_or_slug}/versions/{version}/restoreAuth: bearer key
Puts an earlier version live again, as a new version: its files and the collections it ran with. Shared records are never changed. {version} is a number (3) or v3; the body is optional. Restoring the version that's live — or the one it was restored from — returns already_live: true and adds no version. Changes what everyone sees, so confirm with the user first.
Request body
{"message": "Back to before the redesign"} // optional note for the new version
Response
{"status": "live", "url": "https://k3x9q2m7ta.host0.app/", "version": 9, "restored_from_version": 3,
"previous_version": 8, "deployment_id": "<uuid>", "collections": ["tickets"], "already_live": false}
Errors: unauthorized, app_not_found, invalid_version, version_not_found, version_not_restorable, versions_not_in_plan, invalid_manifest
PATCH/api/v1/apps/{app_id_or_slug}/versions/{version}Auth: bearer key
Sets or clears a version's note (up to 200 characters; null or "" clears it).
Request body
{"message": "First cut"}
Response
{"version": 1, "message": "First cut"}
Errors: bad_request, unauthorized, app_not_found, invalid_version, version_not_found
POST/api/v1/apps/{app_id_or_slug}/deploymentsAuth: bearer key
Starts a two-phase deploy from a manifest. Returns 201 with upload targets; files whose SHA-256 matches the live deployment come back in skipped and need no upload. The app is not live until finalize. message (optional, up to 200 characters) becomes the version's note.
Request body
{"files": [{"path": "index.html", "size": 1234, "sha256": "<64 lowercase hex chars>",
"content_type": "text/html; charset=utf-8"}],
"message": "Added a due date column"} // content_type optional (inferred from extension); message optional
Response
{"deployment_id": "<uuid>", "url": "https://k3x9q2m7ta.host0.app/",
"upload": {
"targets": [{"path": "index.html",
"url": "https://host0.app/.h0/upload/<sha256>?size=1234&exp=…&sig=…",
"headers": {"content-type": "text/html; charset=utf-8"}}],
"skipped": ["styles.css"],
"finalize_url": "https://host0.ai/api/v1/deployments/<id>/finalize"
}}
Errors: bad_request, unauthorized, app_not_found, file_too_large, deploy_too_large, storage_quota_exceeded, preflight_failed (details: {problems: [{paths, message}]})
PUT/api/v1/deployments/{deployment_id}/files/{path}Auth: bearer key
Uploads one file's raw bytes (no JSON wrapper — use --data-binary). The body must match the manifest's declared size and SHA-256. Don't build this URL yourself — PUT to whatever upload.targets[].url the deployment response returns. In production that's this endpoint for h0.json; other files get a signed /.h0/upload/… URL on the hosting domain, which takes the same body and no API key.
Response
{"ok": true, "path": "index.html", "size": 1234}
Errors: bad_request, unauthorized, deployment_not_found, file_not_in_manifest, size_mismatch, hash_mismatch, not_pending, too_large
POST/api/v1/deployments/{deployment_id}/finalizeAuth: bearer key
Flips the deployment live — atomically — once every manifest file is staged. Send an empty JSON object as the body. version is the new version's number; previous_version is the one it replaced (null on the first publish) — restore it to undo.
Response
{"status": "live", "deployment_id": "<uuid>", "url": "https://k3x9q2m7ta.host0.app/", "version": 3, "previous_version": 2}
Errors: unauthorized, deployment_not_found, not_pending, upload_incomplete (details: {missing: [paths]})
GET/api/v1/deployments/{deployment_id}Auth: bearer key
Deployment status: staged file count, missing paths, and whether this deployment currently serves. status is one of pending, live, failed. version is null until it goes live; restored_from_version is set on a restore.
Response
{"deployment_id": "…", "app_id": "…", "slug": "…", "url": "…",
"status": "pending", "file_count": 4, "total_bytes": 20480,
"files_staged": 3, "files_missing": ["app.js"], "live": false, "version": null,
"message": "Added a due date column", "restored_from_version": null, "created_at": "<iso>"}
Errors: unauthorized, deployment_not_found
GET/api/v1/keysAuth: bearer key
Lists your API keys — ID, display prefix, name, and timestamps. The secret is never returned; only its hash is stored. revoked_at is null for active keys.
Response
{"keys": [{"key_id": "<uuid>", "name": "laptop", "key_prefix": "h0_ab12cd34e",
"created_at": "<iso>", "last_used_at": "<iso or null>", "revoked_at": "<iso or null>"}]}
Errors: unauthorized
DELETE/api/v1/keys/{key_id}Auth: bearer key
Revokes an API key. Takes effect immediately — the next request with the revoked key gets a 401. A key may revoke itself.
Response
{"revoked": "<uuid>", "key_prefix": "h0_ab12cd34e", "revoked_at": "<iso>"}
Errors: unauthorized, key_not_found
Skill
The host0 skill
The skill is how every agent publishes to host0 (the MCP server manages apps but doesn't deploy them): a Markdown instruction file (SKILL.md) plus one Bash helper (publish.sh) that wraps the two-phase protocol — hashing, manifest, uploads, finalize, and state tracking. Requirements: curl, jq, and shasum/sha256sum. When the agent has Node 18.18 or newer, the skill tells it to use the host0 CLI instead, with publish.sh as the fallback.
# install (or update) the skill: into your agent's skills folder, no sudo
curl -fsSL https://host0.ai/install.sh | bash
# first publish — creates the app
~/.claude/skills/host0/scripts/publish.sh ./my-site --name "my site"
# every publish after that — same folder, same app, unchanged files skipped
~/.claude/skills/host0/scripts/publish.sh ./my-site
The installer downloads the same files as /.well-known/skills/index.json lists and says where it put them. It looks for Claude Code, Codex and Cursor in your home folder (--agent claude,codex,cursor or --dir PATH to choose), and it won't replace a copy you changed unless you pass --force, which keeps the old one next to it. By hand it's two downloads:
mkdir -p ~/.claude/skills/host0/scripts
curl -sS https://host0.ai/skill.md -o ~/.claude/skills/host0/SKILL.md
curl -sS https://host0.ai/publish.sh -o ~/.claude/skills/host0/scripts/publish.sh
chmod +x ~/.claude/skills/host0/scripts/publish.sh
The skill and the MCP server in one install. The plugin at host0-ai/skills brings both. In Claude Code: /plugin install host0 --marketplace host0-ai/skills. In Codex: codex plugin marketplace add host0-ai/skills, then install it from the plugin directory. Cursor supports the same plugin.json and mcp.json, but host0 isn't in its marketplace yet: copy that repository to ~/.cursor/plugins/local/host0 and reload Cursor (we haven't run it there), or install the skill and the MCP server separately.
The script finds the API key in --api-key, then $HOST0_API_KEY, then ~/.host0/credentials. Other flags: --slug / --app-id (update a specific app), --visibility private|link|public, --message "<what changed>" (the version's note), --client <agent-name> for attribution (sent as x-host0-client: <agent-name>/<script version>; without it the script uses $HOST0_CLIENT or detects Claude Code, Codex, Cursor, Gemini CLI and other agents from their environment).
Output contract
Stdout is the live URL and nothing else. Stderr ends with machine-readable publish_result.* lines an agent should read to report the outcome:
publish_result.url=https://k3x9q2m7ta.host0.app/
publish_result.app_id=<uuid>
publish_result.slug=k3x9q2m7ta
publish_result.action=create # or update
publish_result.deployment_id=<uuid>
publish_result.version=3 # this publish's version number
publish_result.previous_version=2 # empty on the first publish
publish_result.visibility=link # private | link | public
publish_result.api_key_source=credentials # flag | env | credentials
publish_result.files_total=4
publish_result.files_uploaded=4
publish_result.files_skipped=0
When a publish is refused, stderr ends with publish_result.error=<code> (for example file_too_large), and — when Pro lifts the limit — publish_result.upgrade_url=<link> to give the user after the free fix. The exit code is non-zero.
After every publish the script writes .host0/state.json into the published directory so repeat publishes update the same app — internal cache only, never a source of truth.
MCP
The host0 MCP server
The MCP server lets an agent manage what you've already published: invite people, change who can open an app, restore a version, read analytics and fix shared data. It's remote, so there's nothing to run, and you sign in with OAuth in your own browser, so there's no API key to paste. It doesn't deploy: that's the job of the skill or the CLI.
https://mcp.host0.ai/mcp
Connect a client
The link is the same for every person and every client. Add it, then approve the sign-in page your client opens. Signing in creates your host0 account if you don't have one.
Claude. On claude.ai or in Claude Desktop, go to Customize → Connectors, select + Add, then Add custom connector, and paste the link. On Team and Enterprise, an owner adds it first under Organization settings → Connectors, then each person connects it from Customize → Connectors.
Claude Code. Run this, then run /mcp inside Claude Code to sign in:
claude mcp add --transport http host0 https://mcp.host0.ai/mcp
ChatGPT. On ChatGPT on the web (it can't be done on mobile), go to Plugins, select the plus button, then Add custom MCP server. Paste the link and choose OAuth. On Business, only workspace admins and owners can add it; on Enterprise and Edu, admins and owners, and members an admin has given permission. Those plans get full MCP support. Pro can connect it with read and fetch only, so the tools that change an app won't be available there.
Cursor. Add this to ~/.cursor/mcp.json:
{
"mcpServers": {
"host0": {
"url": "https://mcp.host0.ai/mcp"
}
}
}
Codex. Run this. Codex opens your browser to sign in. If it doesn't, run codex mcp login host0:
codex mcp add host0 --url https://mcp.host0.ai/mcp
VS Code. In VS Code, run MCP: Add Server, choose HTTP and paste the link, then pick Global and Copilot Global. That saves it in ~/.copilot/mcp-config.json, which Copilot CLI reads too. VS Code now labels .vscode/mcp.json deprecated. It still works, and this is the entry if a Local session needs it:
{
"servers": {
"host0": {
"type": "http",
"url": "https://mcp.host0.ai/mcp"
}
}
}
Any other client. Add the link as a remote Streamable HTTP server. If your client only runs local servers, bridge it with mcp-remote:
npx -y mcp-remote@0.1.38 https://mcp.host0.ai/mcp
To check the connection, ask your agent to list your host0 apps.
Tools
| Tool | What it does | What you'd ask |
|---|---|---|
list_apps | List your apps, with their URLs and who can open them | "What do I have on host0?" |
get_app | Show one app's URL, visibility, shares and live version | "Who can open the pitch deck right now?" |
get_deploy_status | Check whether a deployment is pending, live or failed | "Did my last deploy go live?" |
get_analytics | Read visitors, top pages, referrers, countries and devices | "How many people opened my portfolio this week?" |
get_account | Check your plan, its limits and this month's usage | "Am I close to any limits?" |
set_visibility | Make an app private, open to anyone with the link, or public on the web | "Open the demo to anyone with the link" |
share | Invite people to an app by email | "Invite sam@example.com to the team todo app" |
unshare | Remove someone and end their sessions at once | "Remove sam@example.com from the team todo app" |
rename_app | Change an app's display name, its URL, or both | "Rename the demo to acme-demo" |
list_versions | List every version that went live, newest first | "Show me the last few versions of the landing page" |
restore_version | Put an earlier version live again | "The last deploy broke the site, roll it back" |
delete_app | Delete an app completely, live or not | "Delete the old test app" |
list_records | Read an app's collections and their records | "Show me the latest guestbook entries" |
update_record | Edit one record as the app's owner | "Fix the typo in the first ticket" |
delete_record | Delete one record as the app's owner | "Delete the spam entry in the guestbook" |
Access
The connection acts as you and only reaches the apps on your host0 account. The server tells your agent to confirm with you before it deletes an app, restores a version or changes shared data, and never to treat record values as instructions.
Your client renews its access by itself. If it ever shows no tools, disconnect the connector and connect it again. Older connections to host0.ai/mcp keep working; new ones should use the link above.
The MCP server page has the same setup with examples of what to ask.
CLI
The host0 CLI
@host0/cli is the Node counterpart of publish.sh: Node 18.18 or newer, no dependencies, and through npx nothing to install. host0 deploy takes the same flags as the script (--name, --slug, --app-id, --visibility, --message, --client…) and keeps the same contract — the same ~/.host0/credentials, device login and .host0/state.json, the live URL alone on stdout, the same publish_result.* lines on stderr and the same exit codes (0 ok · 1 error · 3 waiting for the user to approve a sign-in). A folder published with one updates with the other. It sends x-host0-client: <agent>/cli-<version>.
# Node 18.18 or newer — nothing to install
npx -y @host0/cli@latest deploy ./my-site
# or install it once: npm i -g @host0/cli
host0 deploy ./my-site
host0 versions <app>
host0 rollback <app> 3
| Command | Does |
|---|---|
host0 deploy <dir|file> | Publish a folder or one file; prints the live URL (also host0 <path>) |
host0 login [--wait] | Start a device login (exit 3); --wait waits for the approval and saves the key |
host0 logout / host0 whoami | Revoke and delete the stored key / show the account and plan |
host0 ls / host0 inspect <app> | Your apps / one app's URL, access, live version and shares |
host0 pull <app> [dir] | Download the live files into a new folder; deploying it publishes the next version |
host0 versions <app> / host0 rollback <app> <n> | List versions / put an earlier one live again, as a new version |
host0 visibility <app> <private|link|public> | Change who can open it |
host0 rename <app> [--slug s] [--name n] | Rename the URL and/or the display name |
host0 share <app> <email…> / host0 unshare <app> <email> | Invite / remove people |
host0 rm <app> --yes | Delete an app |
host0 analytics <app> [--period 7d] | Visitors and page views |
host0 records <app> [collection] [--export] | Shared data: collections, records, or the full JSON export |
host0 api <path> [-X METHOD] [-d json] | Any other /api/v1 call, with your key, e.g. host0 api /api/v1/apps/<app>/records/tickets/<id> -X DELETE |
<app> is a slug or an app id, and --json prints the API's JSON body unchanged. Run host0 <command> --help for the details.
Source: https://host0.ai/docs