Projects
Where your sites live: create one, deploy a build, roll back to any release.
Also for this capability: API · CLI · SDKs
Where your sites live: create one, deploy a build, roll back to any release.
| Base URL | https://api.hanzo.ai |
| Operations | 29 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1231 · Projects — The Site Store — Draft · read the specification →
projects is where an org's sites live: create a project, deploy a build,
promote a release, roll back to any earlier one. It is the one org-scoped
store of buildable, deployable sites, shared by every surface that shows a
user's projects — the builder and the console render the same rows because
both call this one surface (apps/projects/projects.go:1-13). It is
implemented in hanzoai/cloud at apps/projects. This HIP states the target
surface — one address, /v1/projects — and carries the browser tag door,
formerly HIP-1068, which is a projects address because the project store is.
Motivation
The capability answered at five top-level addresses for one store, including
a fourteen-path mirror of the sites surface under another capability's name.
A reader met SitesApi, ProjectsApi, EdgeApi, TagsApi and half a
PlatformApi for one subsystem, and two spellings of every release
operation. One store is one capability (HIP-0139 §7.1); the mirror is an
alias, and there is no alias.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
One store
The capability owns one store: the system-namespace projects SQLite
database (apps/projects/store.go:211) — project rows, deployment rows,
release state, and the global site_hosts table
(apps/projects/store.go:267), which binds a globally-unique public
hostname to exactly one (org, slug), first-come, with status separating
holding a name from serving it. Site bytes live in our S3 under
<org>/<slug>/; the store holds the pointer that makes one release live.
The addresses
Every route is under /v1/projects:
- the collection and per-slug lifecycle — create, list, get, update, delete, deploy, deployments, purge, domains;
- the site collection ops at
/v1/projects/sites— generate from a brief, deploy a raw file manifest, fork, list the org's live sites; - releases per slug — publish, promote, list, activate (the rollback);
/v1/projects/edge— the CDN state door: provider, reach, cache policy for the org's published sites (apps/projects/edge.go);/v1/projects/tags— the browser tag door below.
The router today still serves four stray roots — /v1/sites, /v1/edge,
/v1/tags, and the /v1/platform/sites mirror; each pair is a line in
cloud's openapi/misfiled.txt. The first three fold here; the mirror is
deleted, not folded — an alias is not one of the ways a misfiled pair closes
(HIP-0139 §7). Per-slug operations that already carry the /v1/projects
spelling keep it; the second spelling goes.
Operations are typed except the two archive uploads, and the reason is the
wire, not effort: their request body is bytes — a zip or tar, raw or as a
multipart part — and a typed body is JSON-decoded before the handler runs,
so typing them would answer a real archive with 400. Each declares its byte
request through the registry instead (apps/projects/projects.go:424-449).
The tag door
GET /v1/projects/tags tells the hosted analytics tag which client-side
pixels a site has connected, so it can inject them first-party. It is one
public read, resolved per site — from the publishable key when it names a
project, otherwise from the request host — and it MUST return non-secret
identifiers only: the pixel ids a page would carry in its own markup anyway.
Without a resolvable site it answers an empty set at 200, never an error —
a page's tag configuration is fetched during load, and a broken page is
worse than an unmeasured visit. It MUST be served by the process that holds
the project store: served anywhere else it reads nothing and answers empty
with a 200, the answer that carries no signal (apps/projects/tagdoor.go).
Tenancy
The org is minted by the gateway from the validated IAM JWT (HIP-0026) and
is not an input to any handler; a deploy debit and every cap are keyed on
the resolved caller org, hardened against a masquerading admin, with the
validated project sub-scope threaded so a forged X-Project-Id can neither
hard-stop nor evade a cap (apps/projects/billing.go:30-45). The tag door
is deliberately public and can address no other org's project: a key that
names no project falls back to the host, and a host that names no site
yields the empty set rather than a default.
Metered
The capability is metered (plugin/projects/main.go:22): one flat per-deploy
hosting debit — gate, then work, then meter once on success — at
CLOUD_HOSTING_FEE_CENTS ($1.00 default; 0 makes deploys free and
therefore un-gated), attributed to provider hosting, kind deploy,
through the shared cloud.ResourceMeter (apps/projects/billing.go:22-27).
A failed deploy is never billed; a redeploy is a distinct billable event
that returns the same URL.
Events, observability, stage
The capability publishes no events on the bus, so a customer's webhooks
receive nothing from it. It emits build.started, deploy.live and
deploy.failed on the in-process lifecycle stream
(apps/projects/deploy.go:311,179,162), best-effort and detached, so the
chat notifiers can post about a site going live. Beyond the request span it
emits structured log lines only. Its stage is ga: the manifest row carries
no stage field, and absent means ga (HIP-0139 §8).
Upstreams
The capability forks nothing. It links the Hanzo S3 client (hanzos3/go)
to upload site bytes; serving is the static plugin's, and the edge door
reports on it rather than implementing it.
Rationale
The alternative to the fold is the mirror: every release operation published twice, under two capabilities, with generated clients disagreeing about which one is real. The alternative to keeping sites inside projects is a split — but the site rows, the release pointer and the hostname table all live in projects' one store, and two apps on one store is the defect HIP-0106 names, so the split is refused by the store rule.
Security Considerations
The wrong implementation leaks through three doors. A tag door that returns
anything beyond publishable identifiers turns a public unauthenticated read
into a secret oracle for any site on the platform. A hostname binding
without the first-come site_hosts table lets one org serve under another's
name — the table is the authoritative binding the site server keys on, and
status is what stops a claimed-but-unproven custom domain from serving. A
deploy path that read the org from the request rather than the validated
principal would deploy into, purge, or bill another tenant's site.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | projects at its own prefix | 29 operations |
| CLI | hanzo projects … | 29 of 29 |
| SDK | ProjectsApi in every published client | 29 methods |
| MCP | tool projects on https://api.hanzo.ai/v1/mcp | 48 operations, 0 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/projects, operation get_projects:
hanzo projects listimport { Configuration, ProjectsApi } from 'hanzoai';
const api = new ProjectsApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getProjects();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import ProjectsApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = ProjectsApi(client).get_projects()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.ProjectsAPI.GetProjects(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, projects_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = projects_api::get_projects(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.ProjectsApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new ProjectsApi(client).getProjects();curl https://api.hanzo.ai/v1/projects \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches projects through the projects tool, which names its 48 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_projects"
}
}
}'Answers 200 with projectsProject[] — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/projects/{slug}/deploy | Upload a built site as one archive and serve it |
POST /v1/projects/{slug}/deployments/{id}/complete | CompleteDeployment is the CI completion hook that flips a queued git deployment to live (or error) once CI has synced the built site to S3. |
GET /v1/projects/{slug}/deployments/{id} | Returns one deployment of a project by id. |
GET /v1/projects/{slug}/deployments | Returns a project's deploy history, newest version first. |
POST /v1/projects/{slug}/deployments | Opens a deployment and hands back a short-lived, prefix-scoped grant to write its bytes straight to object storage. |
POST /v1/projects/{slug}/domains/{host}/verify | Checks the DNS challenge for a pending custom hostname and, when it passes, promotes the host so it begins routing at the edge. |
DELETE /v1/projects/{slug}/domains/{host} | Gives a custom hostname back, so the name is free to reuse. |
GET /v1/projects/{slug}/domains | Returns every custom hostname this site holds: the live ones, plus any pending claim with the DNS records it still owes. |
POST /v1/projects/{slug}/domains | Attaches one or more CUSTOM public hostnames to this org's site. |
POST /v1/projects/{slug}/publish | Promotes a build output into a new release AND goes live with it — create+activate in one call, which is the 99% path. |
POST /v1/projects/{slug}/purge | Flushes the site's edge cache without redeploying anything. |
POST /v1/projects/{slug}/releases/{release}/activate | Points the site at an existing release — the go-live, and equally the ROLLBACK. |
GET /v1/projects/{slug}/releases | Returns a site's releases newest-first, marking the active one — the rollback menu. |
POST /v1/projects/{slug}/releases | Promotes a build output into a new immutable release WITHOUT serving it — the staged half of publishing, for when you want to check a release before… |
GET /v1/projects/{slug}/shot | Get a PNG of the project's live site |
PUT /v1/projects/{slug}/star | Bookmarks a project for the person calling, and answers whether it is starred afterwards. |
DELETE /v1/projects/{slug}/star | Removes the caller's own bookmark from a project, and answers whether it is starred afterwards. |
GET /v1/projects/{slug} | Returns one project of yours by slug — its settings, its live URL and the deployment currently serving it. |
PATCH /v1/projects/{slug} | Changes a project's settings, and only the settings you send. |
DELETE /v1/projects/{slug} | Deletes a project and takes its site off the internet. |
GET /v1/projects/edge | health reports whether a publish reaches readers, rather than whether it was accepted. |
POST /v1/projects/fork | Creates a project seeded from a PUBLISHED EXAMPLE — either a starter-kit template from the ONE embedded gallery catalog, or any live project on the… |
GET /v1/projects/sites/{slug} | Returns one site — the same row ListSites carries, for one slug. |
POST /v1/projects/sites/deploy | Deploys a caller-supplied file manifest — the deploy_site capability an agent calls — and answers with where it went live. |
GET /v1/projects/sites | Returns the org's deployed sites at the pretty URLs they serve at. |
POST /v1/projects/sites | Generates a self-contained, mobile-responsive static site from a natural-language brief and deploys it live in one call. |
GET /v1/projects/tags | The site's browser tag set for the hosted tag — which pixels to inject, by publishable key |
GET /v1/projects | Returns every project your org owns. |
POST /v1/projects | Creates a project — the handle a site is deployed and served under — and answers 201 with it in draft. |
How is this guide?