Publishing a stoop site over HTTP
One authenticated POST publishes a static site and returns its URL. No browser session, no zip, no filesystem, no terminal — for code that needs to put a page on the web while it runs.
Writing any platform.* call? Load the stoop-platform skill first — it
is the full SDK surface, and guessing at this API produces a page that looks
right and silently loses data.
First: should this be a page at all?
| surface | fits | example |
|---|---|---|
| a short reply | a sentence, a number, a yes or no | "all three deploys are green" |
| a long document | prose read top to bottom, or edited afterwards | a postmortem, a spec |
| a page | anything whose layout carries meaning | a dashboard, a sortable table, a chart, a comparison grid |
The test is whether the layout carries meaning. Publish because reading the thing as text would lose something — not because the answer felt important. A paragraph you are proud of is still a paragraph; send it as a reply.
The call
curl -X POST https://stoop.run/api/sites \
-H "authorization: Bearer $STOOP_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"deploy-health","files":{"index.html":"<h1>hi</h1>"}}'
The response carries the site url, and findings (read them — see
below). Limits: 50 files, 25 MB total.
The key
The user mints it once with npx @stoop/cli keys create <name>, and it is
shown only at creation. Ask them to run it — like login, it needs
their account, so do not run it yourself. Read the secret from the
environment; never hold it in source.
A key writes only inside its own namespace: a key named release-bot
reaches release-bot-* and nothing else, so the call above lands at
release-bot-deploy-health.stoop.run.
Never start the name with your key's prefix
The namespace is added for you. So do not send release-bot-deploy-health
even when the human says "call it release-bot-deploy-health" — you would be
asking for release-bot-release-bot-deploy-health. The server strips one
redundant prefix, so this is no longer a broken URL, but it is still the
difference between the name you meant and one you did not.
One name per recurring thing, forever
name is an upsert: the same name always maps to the same URL, so each
publish revises the page people already bookmarked instead of stranding it.
Name the page for what it is, not when it was made — deploy-health,
never deploy-health-jul-30. A new name for the same thing every week is
how you end up with eleven dead dashboards and nobody knowing which is
current. Date the content, not the name.
Send every file, every time
files replaces the site: anything you omit is deleted. "Just
updating the CSS" means sending the HTML too.
So never publish a revision from memory. If you no longer hold what you published — a later session, a fresh process — read the site back first (below) and patch what you read. An approximation publishes just as cleanly as the real thing and silently drops whatever you failed to recall, over a page someone may already be reading.
keep the files you are not editing
Naming a file in keep carries it over untouched instead of resending it:
{"name": "deploy-health",
"files": {"index.html": "<h1>…</h1>"},
"keep": ["logo.png", "styles.css"]}
This is the answer to a file you cannot hold — a 2 MB image has no useful representation in your context, and its bytes are already on the server. Keeping one is free; resending it means downloading it first. Kept files are still part of the site, so they count toward the 50-file and 25 MB limits.
Every name must be in the site as it stands right now. One that isn't is a
400 naming it, never a quiet publish without it — so a keep list you
carried over from an older session fails loudly instead of deleting the file
it was meant to protect. Read the site back if you are unsure what it holds.
Files may be text or binary
A file is either a plain string (UTF-8 text, the common case) or an object saying which it is — the same shape a read-back returns, so what you read can be published again unchanged:
{"index.html": "<h1>hi</h1>",
"logo.png": {"encoding": "base64", "content": "iVBORw0KGgo..."}}
Visibility is a decision, not a default
Sites published this way are private by default — only members of the key's organization can open them.
- Internal data (metrics, customer names, anything out of a private system): leave it private.
- "Make it shareable", "send this to the customer", a link going into a
public channel: pass
"visibility": "public". Private is the wrong answer here — to anyone outside the org a private site is a sign-in wall they cannot pass. - Unsure: private, and say so in your reply so the human can ask for public.
Retention
Sites expire 7 days after their last deploy, and every deploy pushes that out — a page you keep revising never expires under a live link. The org can make them permanent with the "Keep sites" switch on the dashboard's API keys screen.
Read the findings
Every publish runs the same static checks stoop check runs and returns
them as findings, empty when the site is clean:
{ "code": "unknown-sdk-member", "severity": "warning",
"path": "index.html", "subject": "platform.db.query",
"message": "platform.db.query does not exist",
"hint": "available: platform.db.collection(name)" }
message and hint are prose and may be reworded at any time. If your
code decides anything — gate on errors, ignore a class of warning, check
whether last publish's problem is gone — branch on code and subject,
never on the sentence. code is one of no-root-index,
reserved-path, sdk-not-loaded, unknown-sdk-member,
dangling-ref; code + path + subject together identify a
finding across publishes.
Findings never block the publish — the site is live either way. They exist to catch the SDK method you half-remembered, which is the most common reason a page published this way looks right and does nothing. Read them, fix the page, publish again over the same name.
Send "dryRun": true to validate without publishing: same findings, no
site created, nothing deployed.
Did it work?
To confirm the page is up, fetch the URL you published — without following redirects:
curl -s -o /dev/null -w "%{http_code}" --max-redirs 0 <url>
200— published, public.401, or a302to/login— published, private, working. This is success, not a broken page. Both carryx-stoop-auth-required: 1.404— the publish did not land.
fetch() follows redirects by default, so a naive check of a private site
returns 200 carrying the dashboard's login page — which reads as "the
site published broken" when nothing is wrong. Pass
redirect: "manual", or compare the final URL against the one you
published.
What have I already published?
curl -H "authorization: Bearer $STOOP_API_KEY" https://stoop.run/api/sites
Your key's namespace only, newest deploy first. Across sessions this is the only way to know whether a name is already taken by one of your own earlier pages — check before publishing something "new".
Read a site back
curl -H "authorization: Bearer $STOOP_API_KEY" \
https://stoop.run/api/sites/<name>
Returns the site's current files — every file, exactly as served — plus
its lastDeployedAt. One address per site, and it takes the same name
you publish under; the full slug from the response (release-bot-notes)
works too, so whichever one you kept is the right one.
This is what makes revising safe. Read, change the one file you mean to change, send the whole map back:
const site = await (await fetch(url, { headers })).json();
site.files["index.html"] = { encoding: "utf8", content: nextHtml };
// every other file goes back untouched — including any binary one
await publish({ name: "notes", files: site.files });
Hand back the files map whole. Filtering it to "just the text ones"
deletes the rest, because a publish still replaces the entire site. keep
is the cheaper half of the same rule: read to find out what is there, then
name the untouched files instead of shipping them back.
Limits mirror the publish limits — 50 files, 25 MB — because a site you cannot re-publish is not one this can usefully hand you. A larger site was deployed from a folder with the CLI and has to be revised the same way.
Publishing only if nobody beat you to it
Reading a site and publishing it back is a read-modify-write, and two of them
overlapping lose the earlier edit with a 200 on both sides. Send the
lastDeployedAt you read as an If-Match to make the publish conditional:
curl -X POST https://stoop.run/api/sites \
-H "authorization: Bearer $STOOP_API_KEY" \
-H "if-match: 2026-07-30T09:12:44.108Z" \
-H "content-type: application/json" -d @site.json
412 means somebody published in between: read the site again, re-apply
your change to what you just read, and retry. Do not retry without the
re-read — that is the lost update you were avoiding.
Worth it whenever a page has more than one writer — a schedule and a person, or two of your own runs. Skip it and the last publish wins.
Removing a site
curl -X DELETE -H "authorization: Bearer $STOOP_API_KEY" \
https://stoop.run/api/sites/<name>
204, and the address 404s from then on. Same address as the read, and it
takes the name or the full slug. A key deletes only inside its own
prefix; anything else is a 403. If-Match works here too, if you want
the delete to refuse a site that changed since you looked at it.
Deletion takes the whole site and cannot be undone: the files, anything visitors uploaded, and the site's database. To change what a page says, publish over the same name instead — that keeps the URL people already have.
So a site you published to try something out, or under a name you regretted the moment you saw it, is not permanent. Delete it rather than leaving it in the namespace for the next session to puzzle over.