Recipe: ship without git
What you get
You get the same kind of static site as
Recipe: static site. Here
commit_files puts the whole site onto the app. commit_files is a tool
call with a {path: contents} map. The platform turns that map into one
ordinary commit on the app's own repo. There is no shell, no clone and no
push.
Use this recipe in two cases. The first is a client with no shell
access, such as an MCP client that cannot run a command. The second is a
machine where neither git transport can push: there is no usable SSH key, and
XHOSTD_TOKEN is not set. If git can push from the machine where you work,
follow Recipe: static site.
Its four steps are the same on every template.
commit_files writes real commits with your name on them. They go into the
same repo that a push goes to, and a later git clone shows them like any
other commits. A changeset sends the text you give it through the model's
context each time. files sends a whole file. edits and patches send only
the region that changes, so for a file that already exists, a one-line fix in
a 78 KB file sends the anchor and not the file.
The worked example is live at recipe-commit-files-docs.randomimity.app. It is the two files below, and the transcript in this guide deployed them. Git did not run in that transcript, on any machine, at any point.
The files
There are two files at the repo root: the page, and the stylesheet that the page links to. A later changeset names neither file, and the platform then leaves the stylesheet as it is.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shipped without git — xhostd recipe</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>Shipped without git.</h1>
<p>
Every file on this page arrived through <code>commit_files</code> — a
single tool call carrying a <code>{path: contents}</code> map, which the
platform turns into one ordinary commit on the app's own repo.
</p>
<p>
With a shell, <code>git push</code> sends only what changed. Without
one, <code>commit_files</code> writes each change as one commit.
</p>
<p><a href="https://docs.randomimity.com/guides">xhostd recipes</a></p>
</main>
</body>
</html>
style.css
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh;
display: grid; place-items: center;
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background: #0b0d12; color: #e6e9ef;
}
main { max-width: 34rem; padding: 2rem; }
h1 { font-size: 1.75rem; margin: 0 0 .5rem; letter-spacing: -.02em; }
p { margin: 0 0 1rem; color: #9aa4b8; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .9em; background: #171b24; padding: .15em .4em; border-radius: 4px;
}
a { color: #7aa2f7; }
The static template serves index.html from the repo root at /. It
serves style.css at /style.css, which is the path in the page's <link>.
The template serves each committed file at its equivalent path, and it does
nothing else.
The deploy
This path keeps two acts separate, exactly as git does.
commit_files stores your code, but it does not deploy your code.
deploy is its own explicit call, and it takes the sha from the changeset.
This transcript shows that separation: it makes three commits, and it deploys
two of them.
Do these five steps in order.
1. Create the app. This call makes the prod channel, its hostname and
its git repo. You get a repo even if you never push to it.
create_app(name="recipe-commit-files", template="static")
→ {"name": "recipe-commit-files",
"owner_username": "docs",
"role": "owner",
"repo_url": "https://git.randomimity.com/docs/recipe-commit-files.git",
"template": "static",
"channels": [{"name": "prod",
"hostname": "recipe-commit-files-docs.randomimity.app",
"git_ref_binding": null,
"follows_branch": false,
"current_sha": null,
"status": "provisioning",
"pending_deploy": null}],
...}
Later calls take the app's name and the channel's name, such as prod. This recipe has no credential step.
commit_files uses the same authenticated tool session as every other call.
You thus make no token, store no token and paste no token.
2. Commit the whole site in one changeset. The files map is
{path: contents}. A string value writes the file at that path, and it
replaces the file if the file exists.
commit_files(app_name="recipe-commit-files",
message="the whole site, in one changeset",
files={"index.html": "<!doctype html>\n<html lang=\"en\">\n...",
"style.css": ":root { color-scheme: light dark; }\n..."})
→ {"sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095"}
The transcript above shortens the two values. In the real call each value is the full text of its file from the section above, with all the newlines. That is the cost: both files went through the model's context in full, for one commit.
list_source_files reads the branch back. Call it after every changeset until this
path is familiar to you:
list_source_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "style.css", "kind": "blob", "size": 563}]}
3. Deploy that commit.
deploy(app_name="recipe-commit-files",
channel="prod",
sha="0bf2770b0ab04b119bc4138faf2d9b1a270ab095")
→ {"deploy_id": "df8750a5-ada3-45fe-b3b1-1543ec14873d",
"channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
"status": "queued"}
Use sha on this path, not ref. commit_files returns the exact commit
that it wrote, so you deploy that commit. ref binds the channel; sha
deploys once. ref="master" deploys the branch's current head and binds the
channel to master, so a later deploy can omit both. sha wins if you give
both. deploy
returns as soon as it queues the deploy, and the work runs asynchronously.
Follow it with get_logs and source="deploy": the first line of its reply states the
outcome.
4. Change one file, and only that file. The platform does not change a path that the map does not name. This changeset names one new path:
commit_files(app_name="recipe-commit-files",
message="add robots.txt",
files={"robots.txt": "User-agent: *\nDisallow:\n"})
→ {"sha": "9664363b9339eec27087c819f49c14c977799134"}
list_source_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "9664363b9339eec27087c819f49c14c977799134",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "robots.txt", "kind": "blob", "size": 24},
{"path": "style.css", "kind": "blob", "size": 563}]}
That list is the reason for this step. The changeset named robots.txt
and no other path. index.html and style.css did not change, and they keep
the same sizes as after commit 1: 815 and 563 bytes. A changeset is a sparse
patch, not a full picture of the tree. The platform applies it on top of the
branch's current head. If you omit a file, the platform leaves that file as
it is. It never deletes the file. Send only the paths that change.
5. Delete it again. A null value removes the path.
commit_files(app_name="recipe-commit-files",
message="drop robots.txt again",
files={"robots.txt": null})
→ {"sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972"}
list_source_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972",
"files": [{"path": "index.html", "kind": "blob", "size": 815},
{"path": "style.css", "kind": "blob", "size": 563}]}
A null value is the only way to delete a path through a changeset. That is
why an absent path means "leave the file as it is". The two actions need
different forms, and a map holds one value per path. Then comes the second
deploy, of c16fdc6a:
deploy(app_name="recipe-commit-files",
channel="prod",
sha="c16fdc6adcc7b64b72603a674c05aef8ca708972")
→ {"deploy_id": "2acbf6ad-91b6-40ce-9e43-c3d1840ffb3b",
"channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
"status": "queued"}
No deploy ever carried robots.txt. The file existed in one commit only,
in the repo and nowhere else. The site served commit 1 for that whole period.
The next deploy came after the file was gone. That result is not a fault. It
shows you that a commit is not a deploy. A git push with no deploy after
it gives the same result.
Change a file without resending it
files sends whole content, for a new file. For a file that already exists,
send the region that changes. Two fields do that,
and one changeset takes any mix of files, edits, and patches. A path
belongs to exactly one of them.
edits replaces an anchored region:
commit_files(app_name="recipe-commit-files",
message="widen the card",
edits={"style.css": [
{"old_string": " max-width: 40rem;",
"new_string": " max-width: 52rem;"}
]})
old_string must appear exactly once in the file. If it appears zero times or
more than once, the whole changeset fails and nothing is written. The error
tells you the count. Add the lines around it until the anchor is unique, or set
"replace_all": true when every occurrence is meant to change.
patches takes hunks, which suit several scattered changes to one file:
commit_files(app_name="recipe-commit-files",
message="bump the version banner",
patches={"index.html": "@@ <footer>\n- <p>v1.4</p>\n+ <p>v1.5</p>\n"})
A hunk header is @@, or @@ anchor where the anchor is a line or a unique
substring copied from the file. Put the anchor on a line the hunk covers, or on
the line just above it: matching starts there and runs to the end of the file.
Body lines start with a space for context, - to remove, or + to add. There are no line numbers and no line counts, so a
miscounted header cannot happen. Each hunk needs at least one context or -
line, because those lines are what locate it.
Both fields match byte for byte. Whitespace, indentation, and line endings all
count. Copy anchors from read_source_file output, and do not retype them.
Verify it
get_logs(app_name="recipe-commit-files",
channel="prod",
source="deploy",
deploy_id="df8750a5-ada3-45fe-b3b1-1543ec14873d")
That is the first deploy. The reply starts with a status header, then the log. The transcript shows short ids. It omits the nginx entrypoint lines and the routine per-channel lines for the network and the channel row:
deploy df8750a5 — success (sha 0bf2770b0ab0)
started: 2026-07-31T23:20:42Z finished: 2026-07-31T23:20:44Z
[2026-07-31T23:20:42+00:00] deploy begin id=df8750a5-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] git_sync sha=0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:42+00:00] git_sync ok: synced app=b882c472-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] start_container template=static
[2026-07-31T23:20:43+00:00] start_container ok: container=07e5fbe4b902...
[2026-07-31T23:20:43+00:00] health_check container=07e5fbe4b902... port=80 timeout=10.0s
[2026-07-31T23:20:43+00:00] health_check ok
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: nginx/1.31.3
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: start worker processes
[2026-07-31T23:20:43+00:00] [container] 10.77.1.5 - - [31/Jul/2026:23:20:43 +0000] "GET / HTTP/1.1" 200 815 "-" "Python-urllib/3.13" "-"
[2026-07-31T23:20:43+00:00] caddy ensure_route hostname=recipe-commit-files-docs.randomimity.app upstream=10.77.1.5:32050
[2026-07-31T23:20:43+00:00] caddy ensure_route ok
[2026-07-31T23:20:43+00:00] pinned deployed sha 0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:44+00:00] deploy success
The first line states the outcome. status is one of queued, running,
success, or failed. Read the status from that header, not from the log
text. Poll get_logs while the status is queued or running.
success means the deploy is done, and on failed the reason is in the log
tail.
git_sync is the first step, and it tells you the main fact. The deploy
fetches a commit from a git repo, exactly as it does after a push. At that
point nothing separates this deploy from a deploy after a push. The deploy
cannot see how the commit came into the repo.
The log holds no [build] line. This is the static template. It builds
nothing, it gives the committed files to nginx, and the whole log covers two
seconds. health_check ... port=80 timeout=10.0s is the static template's
window. nginx already runs, so the probe waits for no process of yours. The
probe itself is the "GET / HTTP/1.1" 200 815 line, from nginx's own access
log. Recipe: static site
reads a static deploy log in full.
The second deploy's log is the same log with two more lines. The first deploy does not print them, because it replaces no container:
[2026-07-31T23:26:36+00:00] caddy ensure_route ok
[2026-07-31T23:26:36+00:00] stop_and_remove old container=07e5fbe4b902...
[2026-07-31T23:26:36+00:00] stop_and_remove ok
07e5fbe4 is the container from the first deploy. xhostd retires it only
after the new container passes its health check, and after the hostname
points at the new container. A failed redeploy thus keeps the previous
version live.
Then the proof, against the live demo:
$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.randomimity.app/
200 text/html 815
$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.randomimity.app/style.css
200 text/css 563
815 and 563 are the byte counts of the two files in this guide. Both bodies are byte-identical to those files. The number 815 is also in the health probe's access line above, because nginx serves the same file there.
A later git clone of https://git.randomimity.com/docs/recipe-commit-files.git
shows the three commits above, in order. Its working tree is byte-identical
to the two files here. That is the last fact about commit_files: it makes
ordinary commits, on the same repo that a push uses. On a machine with git,
you clone that repo and continue your work.
When it goes wrong
The app has a connected source repository
commit_files is refused outright. On an app connected to a GitHub repo
with a deploy key, the refusal reads:
this app's code comes from its connected GitHub repository, and its xhostd repository is a read-only mirror of it, so commit_files writes nothing here. A change reaches this app when someone pushes it to the GitHub repository: each push syncs once the repository pings xhostd (sync_git_from_remote shows the setup), and each deploy fetches from GitHub first.
On an app connected to a Cursor Origin repository, the refusal reads:
this app's code comes from its connected Cursor Origin repository, and its xhostd repository is a read-only mirror of it, so commit_files writes nothing here. A change reaches this app when someone pushes it to the Cursor Origin repository: each push syncs, and each deploy fetches first.
There is no way around this refusal. On a connected app, the source
repository is the source, and the xhostd repo is a read-only mirror of it.
Send the code to the source repository, and xhostd syncs it from there. This
guide describes the message and does not capture it. The demo app has no
connected source, and a connection would stay in place.
sync_git_from_remote does not put code onto an app either: it refreshes
that mirror.
The commit succeeded but the site did not change
commit_files returned a sha and did no more. It stores code, but it does
not deploy code. Call deploy with that sha. A push to git with no deploy
gives the same result, so this failure occurs on both paths. If you do not
know which commit is live, call get_app for the channel's current_sha.
Call list_source_files for the content of the repo. If the two disagree, the repo
is ahead and a deploy is absent. A non-null pending_deploy on the channel
means the deploy is in flight and current_sha has not caught up yet — poll
get_logs with source="deploy", and do not start another deploy.
You send the whole file on every edit
It works. The platform applies every changeset on top of the branch head, so
a file that you send again with no change does no damage. But it costs you
that text every time. Two rules keep the cost down. Name only the paths that
change, because the platform leaves every other path as it is. Then send a
file that already exists with edits or patches, not with files.
If git can push from the machine where you work,
Recipe: static site gives
the four steps, and a push sends the diff.
The file you need to commit is not text
The values in files are strings, and the platform writes them into the repo
as UTF-8. There is no binary form and no base64 alternative. An image, a
font, a favicon or a zip file cannot go through commit_files. A static site
with such a file needs git, or a URL to a file on another host. This guide
describes this limit and does not capture it, because the worked example is
two text files. The platform also checks each path. A changeset path must be
relative, and it must contain no . or .. segment.