host0 lets agents publish websites and apps to live URLs in seconds...
host0 hosts whatever your agent created β HTML, sites, apps with a shared database, Markdown, PDFs β at a link you can share, as easily as sharing a Google Doc. Works with any agent.
The core primitive is an app: publish any file or folder β a directory of static files, or one file on its own that opens in a browser (a PDF, an image, a single HTML page, video, audio, Markdown, CSV, text; an archive or another download-only type is rejected β extract a zip and publish the folder) β and get a live URL at https://{slug}.host0.app/ (local dev: http://{slug}.localhost:3000/). Every app is private by default β only the owner (signed in to host0) and people invited by email can open it. Opening it to anyone with the URL (no sign-in; unlisted and never indexed) is opt-in: --visibility link. --visibility public goes further β public on the web, search engines may index it β and is only for things the user wants found in search. A new app's URL is random (k3x9q2m7ta.host0.app); rename it to something readable (team-todo.host0.app) when the user wants a nicer link β see "Rename the URL" below.
An app can also hold shared data: declare record collections in an h0.json and its frontend gets a database on its own origin, no server code involved. See "Shared data" below β that's what turns a page into a tool a team uses together.
Full docs live at https://host0.ai/docs β read them when the user asks how something works, what's possible, or before telling them a feature is unsupported.
The API also describes itself. The machine-readable index:
curl -sS {base}/api/v1
It returns the current endpoint list, file caps, and the platform contract (what host0 serves today). Send the API key (-H "authorization: bearer $HOST0_API_KEY") and limits and plan are the user's own β caps depend on the account's plan; without a key you get the defaults for a new account. GET {base}/api/v1/account adds what the account has used this month. If this skill text and live API behavior disagree, trust the live API.
To install or update this skill: npx skills add host0-ai/skills (any agent the skills CLI supports; source: https://github.com/host0-ai/skills), or fetch two files: https://host0.ai/skill.md β save as SKILL.md, and https://host0.ai/publish.sh β save as scripts/publish.sh next to it, then chmod +x scripts/publish.sh. The script's URL is /publish.sh β there's no /scripts/ in it.
curl, jq (the script dies with a clear message if either is missing)$HOST0_API_KEY or ~/.host0/credentials. With none, publish.sh starts a device login for you β the user approves a link + code in their own browser (see "Getting an API key")./scripts/publish.shhost0.ai (the API) and host0.app (uploads and the live apps). In a sandbox with an egress allowlist, if a request fails with SSL_ERROR_SYSCALL, a proxy error, or a connection refused/reset every time, tell the user which domain to allow β don't loop on it.SSL_ERROR_SYSCALL / connect timeouts reaching host0.ai. publish.sh retries those by itself (you'll see host0: network hiccup β¦ retrying 2/4 on stderr) β just let it run. If you're calling the API with raw curl, retry the request yourself; for GETs and the device-login poll you can add --retry 3 --retry-all-errors, never for POSTs that create something../scripts/publish.sh {dir} [--name "my app"]
./scripts/publish.sh {file} [--name "my app"] # one file: a pdf, an image, a page
Prints the live URL on stdout (e.g. https://k3x9q2m7ta.host0.app/ β new apps get a random slug; --name is only the display name).
Add --client {your-agent} (e.g. --client claude-ai) when you're not Claude Code, Codex, Cursor or Gemini CLI β the script detects those itself. It only labels deploy stats.
Under the hood this is a three-step flow: manifest β upload files β finalize. The app is not live until finalize succeeds.
File structure: index.html must sit at the root of the directory you publish β the directory's contents become the site root. Publish my-site/ where my-site/index.html exists, not a parent folder containing my-site/.
A single file: pass the file itself (./scripts/publish.sh report.pdf) β no index.html needed. The app's root URL serves that file (in a browser tab it opens in host0's file viewer β see the badge note below; ?download gets the original, and PDFs keep range support for large ones), and it's also reachable at /{filename}. Use this whenever the user just wants to share one document or image.
Deploy built output, not source: if the project uses a build step (React/Vite/Next export, .ts/.tsx/.vue/.svelte), build first and publish the output directory (dist/, build/, out/). Never publish node_modules/, package.json, or secrets (.env, key files) β the API rejects server code and secrets with an actionable message.
Free during the beta, with a badge: host0 is free during the beta, and every app is on the free plan, which adds a small "Hosted on host0" badge fixed to the bottom-right corner (about 28px tall, 12px from the edges) of every HTML page, so leave a little room there (no fixed button or chat bubble flush in that corner) and don't try to hide it. A PDF, image, video, audio or text file (.txt, .csv, .md, .json) opened directly in a browser tab opens in a host0 file viewer: the file fills the window, with a slim bar under it (file name, Download, "Hosted on host0") that never covers it. The raw file is still served to <img>/<video> tags, embeds, fetch(), curl, link previews and search engines; ?download on any file URL downloads the original. Other files (zip, fonts, JS, CSS) are served as they are. Whether the user's plan shows the badge is plan.badge in GET {base}/api/v1 (with their key). If the user asks about pricing or removing the badge: when that index has a plans list, it names each plan, its price and what it changes (a plan with badge: false removes it) β point them at {base}/pricing; when there's no plans list, host0 is free during the beta, and paid plans may come later and may let them remove it.
Analytics, built in: host0 counts visits to every app without cookies β a tiny same-origin script (/.h0/a.js) is added to each HTML page, and opened PDFs are counted on the server. The owner sees visitors, pages, referrers, countries and devices in the dashboard's Analytics tab, with GET {base}/api/v1/apps/{app_id_or_slug}/analytics?period=7d (period: 24h, 7d, 30d, 90d, 365d; 400 invalid_period otherwise; optional filters page=/path, referrer=host or (direct), country=DE or (unknown), device, browser narrow every number β 400 invalid_filter if malformed), or the MCP get_analytics tool. Don't remove the script. If the app sets a Content-Security-Policy, allow 'self' in script-src and connect-src. When the user asks "how many people opened it?", answer from that endpoint.
./scripts/publish.sh {dir} --slug {slug} # or --app-id {uuid}
Without a flag, the script resolves the app from .host0/state.json in the published directory (for a single file: .host0/state.json next to the file, keyed by the file name, so two files in one folder stay two apps), and creates a new app only when nothing matches. Unchanged files are hash-skipped server-side β re-publishing a large site with one edited file uploads one file.
Add --message "what changed" (up to 200 characters) to give the new version a note β e.g. --message "Added a due date column". Do it whenever you publish a change the user asked for; it's what they'll see in the version list.
Every publish that goes live is a numbered version of the app (v1, v2, β¦) β publish_result.version is this one, publish_result.previous_version the one it replaced. Earlier versions are kept for a number of days that depends on the plan β GET {base}/api/v1 (with the key) β limits.account.version_retention_days (0 = only the live version); older ones are deleted automatically.
When the user wants to undo a change ("roll back", "put the old one back", "that broke it"), restore an earlier version β it goes live again for everyone as a new version within a minute, with no re-upload:
# list them, newest first (version, live, message, client, restored_from_version, created_at)
curl -sS {base}/api/v1/apps/{app_id_or_slug}/versions \
-H "authorization: bearer $HOST0_API_KEY"
# restore version 3 (also accepts v3); the body is optional
curl -sS -X POST {base}/api/v1/apps/{app_id_or_slug}/versions/3/restore \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"message": "Back to before the redesign"}'
version (the new one), restored_from_version and previous_version β tell the user "version 3 is live again as version 9", and that restoring version 8 undoes it.h0.json decides which collections are served β one it doesn't declare is hidden (still in the export), and comes back when a newer version is restored.already_live: true and adds no version β don't retry it in a loop.version_not_found: that version isn't kept any more β list the versions and offer one that is; never present an older, pruned version as restorable. 409 version_not_restorable: its files are gone β re-publish them from the user's files instead. 403 versions_not_in_plan: the user's plan keeps only the live version β re-publish the files they want live, and see "When an error has upgrade_url".PATCH {base}/api/v1/apps/{app_id_or_slug}/versions/{n} and {"message": "β¦"} (or null). The MCP server's list_versions and restore_version tools do the same as the calls above, and the app's Versions tab in the dashboard has a Restore button.A host0 app uses one visibility at a time:
private (the default): only the owner and invited emails; visitors sign in to view. Every new app starts here.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), and its URL starts out random β but a link travels, and a renamed, readable URL can be guessed β keep anything sensitive private.public β "Public on the web": anyone can view and search engines may index it (the app's own pages are served with X-Robots-Tag: all). Opt-in, and capped per account (403 public_app_limit_reached, with max_public_apps in the body). Use it only when the user wants the app found in search β a portfolio, a landing page, docs, a public tool. Never for anything sensitive.When the user wants a link anyone can open (a page to send around, a demo, a shared PDF), publish with --visibility link β that's what "make it public" or "share a link" almost always means. Choose public only when they say they want it on Google / in search results; if it's unclear, ask. Shared data works the same on link and public. Set visibility on publish with --visibility private|link|public, or any time via the API:
curl -sS {base}/api/v1/apps/{app_id_or_slug} -X PATCH \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"visibility": "link"}'
If going public returns public_app_limit_reached, tell the user the account is at its limit of apps public on the web, and offer link instead (anyone with the URL, not indexed) or moving another public app back to link.
A new app's URL is a random slug like k3x9q2m7ta.host0.app. To give it a readable one, check the name, then rename:
curl -sS {base}/api/v1/slugs/team-todo -H "authorization: bearer $HOST0_API_KEY"
# β {"slug":"team-todo","available":true} (or available:false + a reason)
curl -sS {base}/api/v1/apps/{app_id} -X PATCH \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"slug": "team-todo"}'
# β {"app_id":"β¦","slug":"team-todo","url":"https://team-todo.host0.app/","old_slug":"k3x9q2m7ta",β¦}
Slugs are 1β63 lowercase letters, digits or hyphens, with no hyphen at the start or end. A taken slug returns 409 slug_taken; a malformed or reserved one 422 invalid_slug. The live deployment, records and shares move with the app, and publish.sh keeps updating it (it tracks the app id). For 30 days the old URL redirects (301) to the new one and stays reserved for the user, then it's released β say so before renaming a link people already have. A deleted app's URL is reserved for 30 days too (it 404s). A slug another account used in the last 30 days returns 409 slug_taken ("was recently used by another app"). The user can also rename it on the Settings tab of the app's dashboard page.
The app's display name (shown in the dashboard and GET /api/v1/apps) is separate from its URL β change it with PATCH {base}/api/v1/apps/{app_id} {"name": "Team todo"} (1β100 characters); the URL doesn't change.
Invite people by email with POST /api/v1/apps/{app_id_or_slug}/shares (body {"emails": ["a@b.com"]}); remove one with DELETE /api/v1/apps/{app_id_or_slug}/shares/{email}.
New shares receive individual invitation emails with sign-in and sign-up links. Check invite_delivery in the response: accepted means the email server accepted it, not guaranteed inbox delivery. failed, not_sent, cooldown, or rate_limited leave access granted; share the app's sign-in link or resend to an existing member with {"emails":["a@b.com"],"resend":true}. Existing shares aren't emailed again unless resend is true. Wait 60 seconds between emails to the same member. Invite at most 10 addresses per request; the account's default email limit is 500 attempts per hour.
The share list does two jobs, and this matters for team tools: on a private app it decides who may view, and on any app β link and public included β it decides who counts as members for shared-record rules (see "Shared data" below). GET /api/v1/apps/{app} returns owner_email and shares; together they are the app's members.
An app is not limited to static content. Put an h0.json at the root of the directory you publish, and the app gets record collections its frontend reads and writes at /.h0/data/{collection} on its own origin. No server code, no connection string, no schema migrations, and because the URLs are relative, no CORS.
This is how you build a small tool a team shares: a todo list, a ticket board, a sign-up sheet.
{
"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"
}
}
}
}
string, number, boolean, json (arbitrary object). Constraints: required, default, maxLength, enum.h0.json β no records, and the site publishes exactly as before.Decide whose data it is before writing h0.json β if the user hasn't said, ask them (one short question: "Should everyone see the same list, or should each person get their own?").
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 that anyone can use from the same URL. Set every action to creator:
{ "records": { "habits": {
"fields": { "name": { "type": "string", "required": true } },
"access": { "read": "creator", "create": "creator", "update": "creator", "delete": "creator" }
} } }
A list then returns only the caller's own records (the owner's view too β the owner sees everyone's through the export). When /.h0/me answers {"anonymous": true}, show a sign-in screen with its signin_url instead of loading data β don't fetch and redirect on page load.
owner is neither: it means only you, the app's owner. Every other visitor is shut out, signed in or not. Use it for an app that is yours alone.
Each action takes an audience:
| Audience | Means |
|---|---|
viewers |
Anyone who can open the app β on a link or public app that includes visitors who are not 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 (see "Shared or per-person") |
["a@b.com", β¦] |
An explicit list of emails |
Defaults: read and create are viewers, update and delete are members. The owner always passes. Audiences are evaluated against the app's own share list, so removing an invite revokes writing at the same moment it revokes viewing.
For a team tool, invite the team by email (POST /api/v1/apps/{app}/shares) and leave writes on members. The app can stay open to anyone with the link (link) β reading is open, editing is not.
The one thing that trips agents up. On a
linkorpublicapp nothing routes a visitor through the sign-in gate β reading is open. So an invited teammate arrives anonymous, and their first edit 401s. Call/.h0/mewhen the page loads and, if it answers{"anonymous": true}, render itssignin_urlas a visible sign-in link β one the visitor chooses to follow, never an automatic redirect on page load. If you only handle 401 on the read path, you will ship a tool nobody can edit and the only symptom is a silent failure on a checkbox.
Two more facts about identity that are easy to assume wrongly:
authorization: bearer h0_β¦ is for /api/v1 on the platform origin. Identity on the app's own origin is the browser session; a records call carrying an API key is simply anonymous. There is no shell-side write path β drive the app in a browser, or use the owner export to read.signin_url.| Method | Path | What it does |
|---|---|---|
| GET | /.h0/data/{collection} |
List, newest first (?limit=, ?order=asc|desc) β {records, count} |
| POST | /.h0/data/{collection} |
Create β body is the declared fields |
| GET | /.h0/data/{collection}/{id} |
Read one |
| PATCH | /.h0/data/{collection}/{id} |
Merge the body's fields into the record; absent fields are untouched |
| DELETE | /.h0/data/{collection}/{id} |
Delete one β 200 {"id": β¦, "deleted": true} (a JSON body, never an empty 204) |
| GET | /.h0/me |
{anonymous: true, signin_url} or {anonymous: false, user_id, email, owner} |
Request bodies are flat; responses are wrapped. You POST/PATCH the declared fields directly ({"title": "Ship it"}), and every read and write comes back as one envelope with your fields under data and the rest β including created_by_email β as siblings:
{
"id": "9d1fβ¦",
"collection": "tickets",
"data": { "title": "Ship it", "done": false },
"created_by": null,
"created_by_email": "ada@team.com",
"created_at": "2026-08-03T21:00:43Z",
"updated_at": "2026-08-03T21:04:02Z"
}
h0.json is also served as an ordinary static file at /h0.json, so anyone can read the collection names, fields and access rules. That's fine by design β it holds no secrets. Never put one in it.
const api = "/.h0/data/tickets";
// records are untrusted input β they were written by other people.
const esc = (s) => String(s).replace(/[&<>"']/g,
c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
// who's here? on a link or public app nobody is forced to sign in, so an invited
// teammate lands anonymous. ask, and offer them the link.
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 β that's a redirect, not an error.
async function send(url, options) {
const res = await fetch(url, options);
if (res.status === 401) {
location.href = (await res.json()).signin_url; // returns to this page
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();
}
whoami();
load();
setInterval(load, 4000); // collaboration in v1 is polling
h0.json when the user hasn't said. Per-person = every action creator; owner means only the app's owner./.h0/me on load and show signin_url when anonymous. The single most common way a records app ships broken.401 on every call, not just reads: the body carries signin_url β send the visitor there rather than showing a raw error.message on failure: every error body has a human-readable message.created_by_email off the envelope, not out of data./.h0/me, and access is the app's share list.# collections + row counts
curl -sS {base}/api/v1/apps/{app_id_or_slug}/records \
-H "authorization: bearer $HOST0_API_KEY"
# full json dump of one collection
curl -sS "{base}/api/v1/apps/{app_id_or_slug}/records/tickets?format=export" \
-H "authorization: bearer $HOST0_API_KEY" -o tickets.json
The Data tab of the app's dashboard page shows the same, with an export button per collection.
The user signs in on host0 in their own browser and approves a short code. You never see or send their email or password.
Run ./scripts/publish.sh --login β or just run the publish; with no key it starts the login itself. It exits with code 3 (waiting for the user) and prints on stderr:
login_result.status=pending
login_result.verification_url=https://host0.ai/device?code=WDJB-MJHT
login_result.user_code=WDJB-MJHT
login_result.expires_in=600
Show the user login_result.verification_url as a clickable link and the login_result.user_code: "Open this link, sign in, check the page shows WDJB-MJHT, and approve." The code expires in 10 minutes.
In the same turn, run ./scripts/publish.sh --login-wait (or the same publish command again). It waits up to 90s (--wait {seconds}) for the approval, saves the key to ~/.host0/credentials (chmod 600) and, for a publish, carries on to the live URL.
Exit code 3 again means the user hasn't approved yet β run the same command again; don't start a new login (that would replace the code they're looking at). An expired code is replaced automatically, with a new link to show.
login_result.status=denied (exit 1) β the user said no. Ask before starting another one.
A publish that fails with host0: the api key from β¦ was rejected (revoked, or saved from another account): a publish only starts a login when there's no key at all, so run ./scripts/publish.sh --login yourself and continue from step 2 β the approved key replaces the saved one.
The raw REST pair, if you can't run the script:
curl -sS -X POST {base}/api/v1/auth/device -H "x-host0-client: {your-agent}"
# β {"device_code":"β¦","user_code":"WDJB-MJHT","verification_uri_complete":"https://host0.ai/device?code=WDJB-MJHT","expires_in":600,"interval":5,β¦}
curl -sS --retry 3 --retry-all-errors {base}/api/v1/auth/device/token \
-H "content-type: application/json" -d '{"device_code": "β¦"}'
# every `interval` seconds: authorization_pending (400) β keep polling; slow_down (429) β use the new interval;
# access_denied (403) / expired_token (410) β stop. approved β {"api_key":"h0_β¦","key_prefix":"β¦","email":"β¦"} exactly once.
Save the returned key yourself, immediately (mkdir -p ~/.host0 && printf '%s' "h0_β¦" > ~/.host0/credentials && chmod 600 ~/.host0/credentials) and never echo it.
If the device login can't be used and you're running on the user's own computer:
curl -sS {base}/api/v1/auth/request-code \
-H "content-type: application/json" \
-d '{"email": "user@example.com"}'
curl -sS {base}/api/v1/auth/verify-code \
-H "content-type: application/json" \
-d '{"email": "user@example.com", "code": "123456"}'
api_key (starts h0_) is shown once β save it yourself, immediately (do not ask the user to run this), replacing h0_your_key_here with the actual key from the response:mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentials
The publish script reads the key from these sources (first match wins):
--api-key {key} flag (CI/scripting only β avoid in interactive use)$HOST0_API_KEY environment variable~/.host0/credentials file (recommended for agents)When none is set, a publish starts a device login instead (--no-login makes it fail). A key picked up by a device login during this run reports publish_result.api_key_source=device; afterwards it's the credentials file (api_key_source=credentials). A login in progress lives in ~/.host0/device.json (chmod 600) until it's approved, denied or expired β internal, never show it.
After every publish, the script writes .host0/state.json inside the published directory (single-file publishes: in the file's folder, with "source": "{filename}" on the entry; directory entries carry "source": "."):
{
"apps": {
"k3x9q2m7ta": {
"app_id": "β¦",
"slug": "k3x9q2m7ta",
"url": "https://k3x9q2m7ta.host0.app/",
"base_url": "https://host0.ai",
"source": ".",
"updated_at": "2026-08-01T10:22:31Z"
}
}
}
It exists so repeat publishes update the same app. Without --slug/--app-id the script considers only entries whose base_url matches the API base it is publishing to, newest updated_at first, and uses the first one the active API key can actually read.
An entry the key cannot read (deleted app, or a state file written by another account) is reported on stderr and dropped from the file rather than silently shadowing a good entry β publish_result.state_pruned counts how many were dropped this run. If every cached entry is unusable the script says so before creating a new app, so a state_pruned above 0 together with action=create means "this published somewhere new" β tell the user, and re-run with --slug <slug> if they meant to update an existing app.
If the lookup fails for any other reason (network, 5xx) the script exits instead of creating a duplicate app.
Treat the file as internal cache only: never present this local file path as a URL, and never use it as the source of truth for visibility or auth.
Read the login_result.* and publish_result.* lines from script stderr:
Exit code 3 with login_result.status=pending: give the user login_result.verification_url as a link and login_result.user_code, say it expires in 10 minutes and that they should check the page shows the same code, then run the same command again.
login_result.status=approved: say "Signed in as login_result.email". The key is saved; it never goes into the conversation.
login_result.status=denied: the user denied it in the browser β ask before trying again.
Always share publish_result.url from the current run β that is the live URL.
Say which version is live (publish_result.version). When publish_result.previous_version is set, add that they can say "roll back" to put version {previous_version} back β then restore it (see "Versions and rollback"), and confirm first, since it changes what everyone sees.
When publish_result.action=create: the URL is random β offer to rename it to something readable (see "Rename the URL"); never promise a name-based URL before publishing.
When publish_result.visibility=link: tell the user anyone with the URL can view it, and that it's unlisted and never indexed but not secret β suggest private if the content is sensitive.
When publish_result.visibility=public: tell the user it's public on the web β anyone can open it and search engines may list it. Suggest link if they only meant to share the URL.
When publish_result.visibility=private: tell the user only they (signed in to host0) and invited emails can open it, and viewers sign in with the email that was invited. Offer to open it to anyone with the link (link) or to invite people by email.
When the publish fails with public_app_limit_reached: the files are live but the app kept its previous visibility; offer --visibility link instead.
When a publish fails, publish_result.error is the machine code (e.g. file_too_large, deploy_too_large, storage_quota_exceeded, public_app_limit_reached, app_limit_reached) β tell the user in plain words which limit was hit, using the script's message (it names the file and the numbers). When publish_result.upgrade_url is there too, follow "When an error has upgrade_url" below.
When publish_result.action=create and publish_result.state_pruned is above 0: say plainly that the cached app could not be reached with this key so a new app was created at a new URL, and offer to re-publish with --slug <slug> if they wanted the old one.
When a publish fails: if the run had just created the app, the script deletes it again so no empty app is left behind, and reports publish_result.cleanup=deleted (with publish_result.cleanup_slug). If cleanup=failed, retry the cleanup yourself with DELETE {base}/api/v1/apps/{cleanup_slug} β or tell the user the app name from publish_result.cleanup_slug and that the app's Settings tab in the dashboard has a delete button. No cleanup* line appears on a successful publish, or when the run updated an app it did not create.
Mention the dashboard at the platform origin (e.g. https://host0.ai/dashboard) for managing apps in a browser.
Never point users at .host0/state.json.
upgrade_urlSome limits depend on the user's plan (file size, deploy size, storage, apps public on the web, version history, analytics). When a paid plan would lift the one that was hit, the error body has an upgrade_url (publish_result.upgrade_url from the script; the MCP tools put the same link at the end of the message). Then:
report.pdf is 4.2 MB and your plan takes files up to 3 MB").link instead of public, move another public app back to link, or delete apps they no longer need.upgrade_url as a link, for the user to open themselves if they want to.Never retry the same publish in a loop β it fails the same way until something changes. Never buy, upgrade or enter payment details for the user. No upgrade_url means no plan lifts it from here: just explain the limit and the free fix.
Never send the user's email address or any other personal data from a cloud sandbox. Sign in with the device login β it sends nothing personal.
Never open or approve a device-login link yourself, and never ask the user for a host0 sign-in code or session. Only the user approves, in their own browser.
Never commit ~/.host0/credentials or .host0/state.json to source control.
Never echo the API key into the conversation unless the user explicitly asks for it.
Avoid --api-key in interactive sessions; the credentials file is the preferred storage.
Never send credentials to a non-default --base-url. The script refuses; only pass --allow-nonhost0-base-url when the user has explicitly confirmed the endpoint (e.g. local development).
Deploy built output, never project source, node_modules/, or .env files.
| Flag | Description |
|---|---|
--name {text} |
App name when creating (default: directory basename, or the file name without its extension) |
--slug {slug} |
Update the existing app with this slug |
--app-id {uuid} |
Update the existing app with this id |
--visibility {mode} |
Set private, link (anyone with the URL, never indexed), or public (public on the web, indexable) after publish |
--message {text} |
A note for this version β what changed (up to 200 characters; longer fails with bad_request before anything uploads) |
--client {name} |
Agent name for attribution, e.g. claude-ai, chatgpt (sends x-host0-client: {name}/{script version}; default $HOST0_CLIENT, else detected for Claude Code, Codex, Cursor, Gemini CLIβ¦) β pass it when the script can't detect you |
--api-key {key} |
API key override (prefer the credentials file) |
--base-url {url} |
API base (default: https://host0.ai) |
--allow-nonhost0-base-url |
Allow sending auth to a non-default --base-url |
--login |
Start a device login (or re-show the pending one) and exit 3 |
--login-wait |
Wait for the user's approval, save the key, exit 0 (3 = still pending, 1 = denied) |
--wait {seconds} |
How long --login-wait / a publish waits for the approval (default 90, or $HOST0_LOGIN_WAIT_SECONDS) |
--no-login |
With no API key, fail instead of starting a device login |
Exit codes: 0 ok Β· 1 error Β· 3 waiting for the user to approve the device login.
Everything else β listing apps, app detail, shares, deployment status, versions β is plain REST per the live index at GET {base}/api/v1, authenticated with authorization: bearer h0_β¦.
DELETE /api/v1/apps/{app_id_or_slug} deletes an app completely, live or not: its URL stops working (404), and its deployments, shares and shared records are deleted with it (the slug stays reserved for the user for 30 days). It cannot be undone β only do it when the user asks, confirm first, and export records first if they matter. To just take an app out of view, make it private instead. The publish script uses the same call to clean up an app whose first deploy failed (an empty app still counts against the account's app cap). The app's Settings tab in the dashboard has the same delete button.
This skill is how agents deploy to host0 β the MCP server has no deploy or create tool. (People can also publish by dropping files on their host0 dashboard in the browser, and upload a new version of an existing app by dropping files on its Versions tab.) If the user also has host0 connected as an MCP server ({base}/mcp), its tools manage apps that already exist: list_apps, get_app, get_deploy_status, get_analytics, list_versions, restore_version, set_visibility, rename_app, share, unshare, and delete_app (deletes an app completely, live or not). Use whichever is at hand for those β they call the same service as the REST endpoints above. To put new files live, always use publish.sh.