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 (period 24h, 7d, 30d, 90d or 365d, plus the same filters), or the MCP get_analytics tool.

  • How long. Page views are kept for 13 months.

  • On which plan. Pro shows all of this for up to 90 days (24h to 90d). On the free plan, host0 still counts every visit, but the tab shows only the visits this month, and the API answers 200 with locked: true and visits_this_month — upgrading shows the history. A period past your plan's limits.account.analytics_days is a 403 analytics_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 with PATCH /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>, or GET /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.json and 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 MCP restore_version tool. 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.json keeps 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_days in GET /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.

  1. 1Create an app (once) — POST /api/v1/apps with {"name": "my app"} returns the app_id, random slug, and URL.
  2. 2Send the manifest — POST /api/v1/apps/{app}/deployments with one entry per file: path, size (bytes), sha256 (lowercase hex of the file bytes), and optional content_type. The response lists upload.targets (files to PUT) and upload.skipped — files whose hash matches the live deployment are hash-skipped, so re-publishing a large site with one edited file uploads one file.
  3. 3Upload each target — PUT the raw bytes to each target's url with its headers. 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, while h0.json uploads 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.
  4. 4Finalize — POST /api/v1/deployments/{id}/finalize flips the deployment live atomically and returns its version number. Until then the previous deployment (or nothing) serves. Check progress any time with GET /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 .java files; host0 doesn't run server code yet.
  • Unbuilt source — .ts .tsx .jsx .vue .svelte files; build first and publish the output directory (dist/, build/, out/).
  • Project source — node_modules/, or manifests like package.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.

LimitFreePro
Max per file3 MB (3145728)50 MB (52428800)
Max per deploy100 MB (104857600)1 GB (1073741824)
Max files per deploy10001000
Stored bytes500 MB (524288000)10 GB (10737418240)
Apps5100
Apps public on the web325
Versions keptLive version only30 days
AnalyticsVisits this month90 days
Visits a month (soft — never blocks)10,000100,000
Hosted on host0 badgeYesNo

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 carries X-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 with X-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.

  1. 1Add it. On the app's Domains tab, or with POST /api/v1/apps/{app}/domains and {"hostname":"blog.acme.com"}, or with host0 domains add <app> blog.acme.com, or the MCP add_domain tool. You get two DNS records.
  2. 2Set the records at your DNS provider: a CNAME from blog.acme.com to domains.host0.app, and a TXT at _host0.blog.acme.com that proves you own it.
  3. 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 goes pending → provisioning → active, usually within a few minutes, and the certificate is issued for you. Only active domains serve.
  • Everything works there. Private apps, invites, shared records and analytics all work on your domain. Plain http redirects to https.
  • Search engines. An app that's public on the web is indexed on its custom domain, and its host0.app address points there as canonical. With more than one domain, the oldest is the canonical one.
  • Use a subdomain. A root domain like acme.com only works if your DNS provider can flatten a CNAME (often called ALIAS or ANAME). 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.json must have a records key, and it's the only top-level key: anything else is refused with invalid_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.

AudienceMeans
viewersAnyone who can open the app — on a link or public app that includes visitors who aren't signed in
membersThe owner plus the emails the app is shared with, even when anyone can open the app itself
ownerThe app owner only — you, not each visitor
creatorEach 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/v1 on 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 data alongside id, created_at, created_by_email and friends. created_by_email is on the envelope, not inside data. 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.json is 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.

MethodPathWhat 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/meWho 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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's Link header 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, so npx skills add https://host0.ai installs 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 .md to its path (/pricing.md, and /index.md for the home page).

Error codes

CodeHTTPMeaning
invalid_hostname400Domains: not a hostname you can use — message says why
domain_limit_reached403Domains: the account holds its plan's limit, 0 on Free — carries plan and upgrade_url
domain_not_found404Domains: this app has no such domain
domain_exists409Domains: this app already has that domain
domain_taken409Domains: another account has verified that hostname
domains_unavailable503Domains: custom domains are off for now — the app's own URL keeps working
bad_request400Malformed body or invalid field (incl. manifest validation and invalid paths)
size_mismatch400Uploaded bytes don't match the manifest's declared size
hash_mismatch400Uploaded bytes don't match the manifest's SHA-256
unauthorized401Missing or invalid API key
invalid_code401The emailed code didn't verify (codes expire after an hour)
invalid_cursor400Records: cursor isn't a nextCursor this API gave out — pass it as it is, or leave it out for the first page
invalid_idempotency_key400Records: the Idempotency-Key header isn't 1–255 printable characters with no spaces
invalid_period400Analytics: period isn't one of 24h, 7d, 30d, 90d, 365d
invalid_filter400Analytics: a filter (page, referrer, country, device, browser) is malformed — message says which and what it accepts
invalid_version400Versions: the version isn't a version number like 3 (or v3)
authorization_pending400Device login: the user hasn't approved yet — keep polling every {interval} seconds
invalid_device_code400Device login: unknown or already-used device code — start a new login
access_denied403Device login: the user denied it — start a new one only if they ask
expired_token410Device login: the code expired (10 minutes) — start a new login
slow_down429Device login: polling too fast — wait the returned {interval} seconds between polls
sign_in_required401Records: 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}
forbidden403Records: signed in, but not in the audience this action allows
app_limit_reached403The 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_reached403The 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_exceeded403The 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_plan403Versions: 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_plan403Analytics: the period is longer than the plan's analytics window — pick a shorter one; details: {max_days, periods, plan}
record_limit_reached403The app is at its max records — delete records you no longer need
app_not_found404No app with that id or slug in your account
deployment_not_found404No deployment with that id in your account
file_not_in_manifest404PUT path wasn't declared in the deployment's manifest
key_not_found404No active API key with that id in your account
share_not_found404That email isn't on the app's share list
collection_not_found404Records: 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_found404Records: no record with that id in the collection
version_not_found404Versions: 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_taken409Another 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_pending409The deployment already finalized or failed — start a new one
upload_incomplete409Finalize before every file uploaded; details: {missing: [paths]}
version_not_restorable409Versions: that version's files are no longer stored, so it can't be restored — publish those files again instead
file_too_large413A 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_large413The 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_large413An 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_large413Records: one record exceeds the per-record byte cap
preflight_failed422The deploy breaks the platform contract; details: {problems: [{paths, message}]}
invalid_manifest422h0.json is malformed; details: {problems: [messages]} naming each line to fix
invalid_slug422The slug is malformed or reserved — 1–63 lowercase letters, digits or hyphens, no hyphen at the start or end
idempotency_key_reused422Records: this Idempotency-Key was already used with different fields — send a new key for a new submit
invalid_record422Records: the body doesn't match the declared fields; details: {problems: [messages]}. 400 when an update has no fields to change
rate_limited429Too 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_limited429Too many deploys started in the last hour — wait, then deploy again
browser_app_limit_reached429Browser 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)
internal500Something 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

ToolWhat it doesWhat you'd ask
list_appsList your apps, with their URLs and who can open them"What do I have on host0?"
get_appShow one app's URL, visibility, shares and live version"Who can open the pitch deck right now?"
get_deploy_statusCheck whether a deployment is pending, live or failed"Did my last deploy go live?"
get_analyticsRead visitors, top pages, referrers, countries and devices"How many people opened my portfolio this week?"
get_accountCheck your plan, its limits and this month's usage"Am I close to any limits?"
set_visibilityMake an app private, open to anyone with the link, or public on the web"Open the demo to anyone with the link"
shareInvite people to an app by email"Invite sam@example.com to the team todo app"
unshareRemove someone and end their sessions at once"Remove sam@example.com from the team todo app"
rename_appChange an app's display name, its URL, or both"Rename the demo to acme-demo"
list_versionsList every version that went live, newest first"Show me the last few versions of the landing page"
restore_versionPut an earlier version live again"The last deploy broke the site, roll it back"
delete_appDelete an app completely, live or not"Delete the old test app"
list_recordsRead an app's collections and their records"Show me the latest guestbook entries"
update_recordEdit one record as the app's owner"Fix the typo in the first ticket"
delete_recordDelete 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
CommandDoes
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 whoamiRevoke 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> --yesDelete 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