Use this reference to implement an Energon client for discovery, authentication, site and loose-file operations, listing, and error recovery. Query the target Energon’s /v1/help for origins, policy, and SOP, then use the advertised /v1/openapi.json document because it is the canonical HTTP schema. Store returned url and api_url values instead of deriving them from route shapes.
HTTP API
Base URLs and authentication
PUBLIC_ORIGIN/v1/... API; bearer except discovery and connection requests
CONTENT_ORIGIN/{handle}/s/{id}/{slug}/... human-facing site content
CONTENT_ORIGIN/{handle}/f/{id}/{filename} human-facing loose-file content
CONTENT_ORIGIN/_grants/{id} upload grant redemption; grant secret, not an API token
CONTENT_ORIGIN/.well-known/agent-skills/ this Energon's rendered agent skill; public, no credentialThe hub origin (PUBLIC_ORIGIN) and content origin (CONTENT_ORIGIN) are different hostnames in production. API responses use:
urlfor the canonical public content URL onCONTENT_ORIGIN.api_urlfor an authenticated raw-byte API URL onPUBLIC_ORIGIN, when the operation has one.
Do not build the content URL by replacing or appending to the API host. Store the returned value.
Authenticated /v1 operations require the header below. Discovery endpoints and the connection request/poll endpoints listed below do not require a bearer token. An upload grant redemption also sends Authorization: Bearer, but with the grant secret on the content origin; a grant secret never works on /v1, and an agent key never redeems a grant:
Authorization: Bearer ENERGON_API_TOKENUse an existing configured credential first and verify it with /v1/whoami. If none exists and a human can respond, use this Energon’s code-based connection flow. In unattended runs, stop until a human provisions a token from /keys. Save a delivered token before using it; Connect an agent covers persistence and ephemeral environments. Read the host’s /auth.md for credential handling and recovery. Tokens cannot be recovered or renewed after expiry. Send the agent key only to the hub’s API, never to published URLs or across redirects.
Request and storage flow
Sites have a generated id, a slug label, and many paths. Loose files have one generated id and one current filename. The API routes both by id; the public content URL additionally includes the publishing handle and, for sites, the slug label. A slug does not resolve a site.
Authenticated site routes are globally namespaced by id. For a site handoff across accounts, store the returned id and public url. Do not look up a site by slug.
Discover this Energon
Replace the placeholder host before running this example:
export ENERGON_ORIGIN="https://hub.your-company.example"
CONTENT_ORIGIN=$(curl -fsS "$ENERGON_ORIGIN/v1/help" | jq -r .content_origin)
curl -fsS "$ENERGON_ORIGIN/v1/health"
curl -fsS "$ENERGON_ORIGIN/v1/help" | jq '{openapi, gateway_openapi, content_origin, agent_skills_url, env, token_prefix, retention, tokens, limits}'
curl -fsS "$ENERGON_ORIGIN/v1/openapi.json" | jq '{openapi, info, servers}'
curl -fsS "$ENERGON_ORIGIN/v1/openapi-gateway.json" | jq '[.paths[][] | objects | .operationId]'
curl -fsS "$ENERGON_ORIGIN/llms.txt"
curl -fsS "$CONTENT_ORIGIN/llms.txt"
curl -fsS "$CONTENT_ORIGIN/.well-known/agent-skills/index.json"/v1/help describes this Energon: origins, token environment and prefix, retention presets, token policy, and limits. Its openapi field names the live OpenAPI document. From v1.14.0, gateway_openapi names the gateway catalog, the document a tool gateway should import instead. From v1.12.0, agent_skills_url names the well-known skill directory on the content origin, or is null when the operator turned it off with AGENT_SKILLS_DISCOVERY or CONTENT_ORIGIN is invalid. /v1/openapi.json describes the HTTP contract: paths, request and response schemas, status codes, and error codes. Read both rather than treating either document as a substitute for the other.
GET /llms.txt is host-branched. On the hub origin it is Energon SOP plus guest-write and upload-grant sections for publishers. On a distinct content origin it is the guest-write and upload-grant redemption protocol only and must not mention /v1, /auth.md, /keys, or /connect. Content-origin /v1/help, /v1/openapi.json, and /auth.md return 404 { error: "not_found" } naming GET /llms.txt on that host. When both origins are the same (local development), /llms.txt is the hub body with the guest-write section included. While the well-known skill is on, hub /llms.txt and /v1/help sop also carry a no-repository install line, npx skills add <content origin> -g.
Well-known skill
From v1.12.0, the content origin serves this Energon’s publish skill, rendered from the running Worker’s own identity: the hub origin, token environment name, token prefix, and skill name match what /v1/help reports and what this Energon accepts. The files hold no credentials and need no Access session or GitHub access. Responses send cache-control: public, max-age=300, access-control-allow-origin: *, and x-content-type-options: nosniff. An operator can turn the paths off with AGENT_SKILLS_DISCOVERY; they then return 404, and edge caches can keep serving the last copy for up to five minutes. npx skills add <content origin> -g installs the skill from this index, and a tool gateway can load the same URL; see Use Energon through a tool gateway.
Operation names and tags
Every operation in /v1/openapi.json has a unique verb-noun operationId and at least one tag. Gateways that turn OpenAPI into tools usually name a tool <first tag>.<operationId>, such as deployments.createDeployment. v1.12.0 added the deployments tag and renamed the twelve deployment and deployment-grant operations that previously had generated names. Any client, SDK, or gateway app that referenced the old names must switch to the new ones.
Under the deployments tag:
createDeployment, formerlypostVSitesIdDeployments:POST /v1/sites/{id}/deploymentsgetDeployment, formerlygetVSitesIdDeploymentsDeploymentid:GET /v1/sites/{id}/deployments/{deploymentId}cancelDeployment, formerlydeleteVSitesIdDeploymentsDeploymentid:DELETE /v1/sites/{id}/deployments/{deploymentId}uploadDeploymentFile, formerlyputVSitesIdDeploymentsDeploymentidFilesPath:PUT /v1/sites/{id}/deployments/{deploymentId}/files/{path}uploadDeploymentArchive, formerlyputVSitesIdDeploymentsDeploymentidArchive:PUT /v1/sites/{id}/deployments/{deploymentId}/archiveprepareDeployment, formerlypostVSitesIdDeploymentsDeploymentidPrepare:POST /v1/sites/{id}/deployments/{deploymentId}/preparecommitDeployment, formerlypostVSitesIdDeploymentsDeploymentidCommit:POST /v1/sites/{id}/deployments/{deploymentId}/commit
Under the grants tag:
getDeploymentGrant, formerlygetDeploymentGrantsGrantid:GET /_deployment-grants/{grantId}uploadDeploymentGrantFile, formerlyputDeploymentGrantsGrantidFilesPath:PUT /_deployment-grants/{grantId}/files/{path}uploadDeploymentGrantArchive, formerlyputDeploymentGrantsGrantidArchive:PUT /_deployment-grants/{grantId}/archiveprepareDeploymentGrant, formerlypostDeploymentGrantsGrantidPrepare:POST /_deployment-grants/{grantId}/preparecommitDeploymentGrant, formerlypostDeploymentGrantsGrantidCommit:POST /_deployment-grants/{grantId}/commit
Routes, request bodies, and responses did not change. Upload grants are mintGrant and getGrant under the grants tag.
In the served document, the /_deployment-grants/* path items carry their own servers: [{ "url": CONTENT_ORIGIN }], because only the content origin redeems grant secrets. The top-level servers stays PUBLIC_ORIGIN. When this Energon has no dedicated content origin, such as a local single-origin run, the path-level entries are omitted and the document still returns 200. A gateway that allows one origin per import, such as Executor, skips those five operations; the machine that holds the grant secret calls them directly.
Gateway catalog
From v1.14.0, GET /v1/openapi-gateway.json serves the subset of /v1/openapi.json that a tool gateway should import. A gateway turns every imported operation into a tool, and an agent that searches those tools before reading the skill picks whatever matches best. If the catalog offers createFile, the agent reads a local file into its context and sends it back as a base64 tool argument, which is slow and can exceed the gateway’s limits. The gateway catalog leaves out every operation that would carry or return bulk bytes that way, so the only publish route left is an upload grant the machine redeems itself.
The gateway catalog keeps 25 operations:
- Grants:
mintGrant,getGrant. - Deployments:
createDeployment,getDeployment,cancelDeployment,prepareDeployment,commitDeployment. - Sites:
listSites,createSite,getSite,patchSite,deleteSite,getSiteFile,deleteSiteFile. - Loose files:
listFiles,getFile,patchFile,deleteFile,duplicateFile. - Cleanup:
cleanup. - Discovery:
getHelp,getHealth,getLlmsTxt,whoami,getGatewayOpenapi.
It leaves out:
- Byte uploads:
createFile,putFile,putSiteFile,importSite,uploadDeploymentFile,uploadDeploymentArchive. - Zip exports:
exportOwned,exportSite. - Content-origin grant redemption: the five
/_deployment-grants/*operations, whether or not this Energon has a dedicated content origin. revokeSelf, because one gateway key usually serves many machines, andstartConnectionandexchangeConnection, because a gateway never runs the connect flow.getAuthMarkdownandgetOpenapi.- Every
/v1/admin/*operation.
getFile and getSiteFile stay so agents can read published text as reference. They return stored bytes, so fetch the public URL instead for large or binary files.
The catalog lists only the hub origin under servers. Bind the gateway’s credential to that origin. The summaries of mintGrant, createDeployment, createSite, and duplicateFile match searches such as “publish”, “share file”, and “upload local file”, and they name the Energon skill, because some gateways show only an operation’s summary in tool search. Executor is one of them.
Removing an operation from the catalog changes what a gateway offers, not what the key can do. The gateway’s key can still call every route its account may use, including the left-out ones. The tool gateway guide covers scoping that key.
openapi/v1.json marks every operation in or out of the gateway catalog with an x-energon-gateway extension. A new operation stays out until it is marked. Neither served document includes the marker.
Agent connections
Use these endpoints only when advertised by the target host. The human flow is described in Connect an agent. Connection requests issue the same account tokens as manual minting; they do not add selectable read/write scopes or per-object read permissions. This is Energon’s connection protocol, not OAuth device authorization.
| Method and path | Credential | Result |
|---|---|---|
POST /v1/connections | No bearer | Send JSON { "label": "my agent" }; receive id, poll_token, user_code, verification_uri (which carries user_code as a query parameter), expires_in, and interval. |
POST /v1/connections/{id}/token | Private poll_token in JSON body | 202 with status: pending while waiting; 200 with id, token, label, and expires_at once approved. |
Keep poll_token private. Show the human only the verification link and user code; the human signs in at /connect and approves or denies the request. Poll no faster than the returned interval (currently five seconds), within the request lifetime (currently ten minutes). Store the delivered agent key without printing it, discard request secrets, and verify /v1/whoami.
| Response | Recovery |
|---|---|
403 connection_denied | Stop; ask the human before requesting again. |
410 connection_expired | Request expired or already consumed. If delivery was lost, have the human revoke the token before approving a new request. |
401 connection_invalid | Stop and check the request credentials. |
429 connection_slow_down | Wait at least five seconds before polling again. |
429 connection_rate_limited | Wait ten minutes before starting another request. |
The token is delivered once. The agent cannot approve its own access, and retrying a consumed exchange cannot recover a token. Consult the live OpenAPI document for complete validation errors and schemas.
Common request headers
| Header | Routes | Meaning |
|---|---|---|
Authorization: Bearer … | Authenticated /v1 routes | Use the token prefix and environment name advertised by /v1/help. |
Content-Type: application/json | Site create/duplicate/PATCH; loose duplicate/PATCH | JSON object body. |
Content-Type: application/zip | Site import; deployment archive upload | ZIP bytes for an existing site or a declared deployment archive. |
Prefer: respond-async | Site import | Return a 202 merge session to prepare and commit instead of merging in one request. |
Idempotency-Key | Site import with Prefer: respond-async | Unix milliseconds, a dot, and a UUIDv4, so a lost response can be retried. |
X-Filename | Loose-file POST/PUT | Required for raw create; optional on PUT to rename while replacing. |
X-Energon-TTL | Loose-file create/duplicate | Content lifetime; multipart field ttl is also accepted. |
X-Energon-Write-Policy | Loose-file create/duplicate | owner or org; multipart field write_policy is also accepted. |
X-Energon-Set-Password | Loose-file create/duplicate/PUT | Set or clear the public share password; multipart field password is also accepted. |
X-Energon-Set-Write-Password | Loose-file create/duplicate | Set or clear the write password; multipart field write_password is also accepted. Creator-only. This is not X-Energon-Write-Password. |
X-Energon-Link-Access | Loose-file create/duplicate | public or private; multipart field link_access is also accepted. Omitted means public. |
X-Energon-Duplicate-From | Loose-file POST | Source file id for server-side copy. |
X-Energon-Password | Public content URL only | Read a share-password-protected public content URL. It is not used on authenticated API GETs and never authorizes PUT or DELETE. |
X-Energon-Write-Password | Public content URL only | Guest PUT (and site-path DELETE) on that public URL. Also unlocks GET. It is not a /v1 header. |
For JSON site requests and JSON loose-file duplicates, use the body fields ttl, write_policy, password, write_password, and link_access. Header-based loose-file duplication uses the X-Energon-* headers only when the request is not parsed as JSON. Valid write_policy inputs are exactly owner and org, and valid link_access inputs are exactly public and private (otherwise 400 bad_link_access). Guest PUT on a public URL rejects those set/replace headers.
Site routes
Route summary
| Method and path | Request | Success |
|---|---|---|
POST /v1/sites | JSON create or duplicate | 201 create/copy |
GET /v1/sites | List query | { sites, total, next_cursor } |
GET /v1/sites/{id} | None | Site metadata plus files, content_generation, and version_id |
PATCH /v1/sites/{id} | JSON metadata patch | Updated site metadata |
DELETE /v1/sites/{id} | None | { ok: true, deleted: id } |
PUT /v1/sites/{id}/files/{path} | Raw bytes | 201 new path; 200 replacement |
GET /v1/sites/{id}/files/{path} | None | Raw bytes |
DELETE /v1/sites/{id}/files/{path} | None | { ok: true, deleted: true, path } |
POST /v1/sites/{id}/import | ZIP bytes | 200 { id, slug, url, written } merge, or 202 session with Prefer: respond-async |
GET /v1/sites/{id}/export | None | ZIP attachment |
POST /v1/sites/{id}/deployments | JSON intent | 201 new session; 200 identical retry |
GET /v1/sites/{id}/deployments/{deploymentId} | None | Session status or committed receipt |
PUT /v1/sites/{id}/deployments/{deploymentId}/files/{path} | Declared raw bytes | 201 first receipt; 200 identical retry |
PUT /v1/sites/{id}/deployments/{deploymentId}/archive | Declared ZIP bytes | Archive receipt |
POST /v1/sites/{id}/deployments/{deploymentId}/prepare | No body | 202 progress; 200 ready. Never publishes. |
POST /v1/sites/{id}/deployments/{deploymentId}/commit | No body | 200 committed receipt |
DELETE /v1/sites/{id}/deployments/{deploymentId} | None | 204 abort of an unpublished session |
The {path} segment may contain slashes. It must be a safe relative path: no leading slash, drive prefix, or .. segment. {id} is the 6-character site id from create. A slug in that segment is 404.
Path PUT, path DELETE, and import are not conditional. Sending X-Energon-Expected-Version to them is 400 bad_expected_version and writes nothing. A site’s generation covers every path, so a conditional site update goes through a deployment with expected_version.
Create or duplicate a site
Create body:
{
"slug": "onboarding-review",
"password": "optional-share-password",
"write_password": "optional-write-password",
"ttl": "7d",
"write_policy": "owner"
}| Field | Required | Meaning |
|---|---|---|
slug | Yes | Lowercase letters, numbers, and internal hyphens; 1–63 characters. Label only; not unique. |
password | No | Public-link share password. |
write_password | No | Guest write password. Creator-only. |
ttl | No | Energon-supported duration, positive seconds, or never when allowed. |
write_policy | No | owner or org for a new site or copy; otherwise this Energon’s default. |
link_access | No | public (default) or private. Private cannot be combined with password, write_password, or write_policy: "org" (400 link_access_conflict). See Keep work private to your account. |
duplicate_from | Copy only | Source site id. Requires a new slug. |
POST /v1/sites always creates (201 plus a new id). The same slug can exist on many sites. Update an existing site by id with PUT, PATCH, import, or the hub catalog. reserved_slug is 400 when the slug is admin.
New-site 201 response fields:
| Field | Meaning |
|---|---|
id | Durable site identity. Keep it. |
slug, handle | Label and owner handle used in the public URL. |
url | Canonical content-origin site root, /{handle}/s/{id}/{slug}/. |
created | true. |
password_protected | Whether the public content URL is gated. |
password | The new phrase when this request set one; otherwise null. Use password_protected to determine the current gate state. Later /v1 reads omit this field. |
write_password_protected | Whether guest write is enabled. |
write_password | The new phrase when this request set one; otherwise null. Later /v1 reads omit this field. |
ttl, expires_at | Resolved lifetime and timestamp; expires_at can be null. |
write_policy | Resolved owner or org. |
link_access | public or private. |
A duplicate request uses the create fields plus duplicate_from (a site id). Its 201 response adds duplicated: true, duplicated_from, and file_count. The copy has caller ownership and a fresh destination TTL/write policy; it does not inherit the source share password, write password, or link access unless the request supplies them. Only the creating account can copy a private site.
Write, read, and delete site paths
export ENERGON_ORIGIN="https://hub.your-company.example"
test -n "$ENERGON_TOKEN" || { echo "Set a human-authorized ENERGON_TOKEN" >&2; exit 1; }
SITE_ID="REPLACE_WITH_SITE_ID"
curl -fsS "$ENERGON_ORIGIN/v1/sites/$SITE_ID/files/assets/app.css" \
-X PUT \
-H "Authorization: Bearer $ENERGON_TOKEN" \
-H "Content-Type: text/css" \
--data-binary @dist/assets/app.cssSite-file PUT returns { url, api_url, path, size, content_type }. It does not create a missing site and does not extend expiry. GET returns raw bytes with the stored content type. DELETE removes only that path and returns { ok, deleted, path }. Substitute the site id from create; a slug in that path is 404.
Read site detail
GET /v1/sites/{id} returns:
- Site fields:
id,slug,handle,url,created_at,updated_at,created_by,last_written_by,password_protected,write_password_protected,expires_at,last_read_at,write_policy,link_access. files: ordered path objects withhandle,id,slug,path,size,content_type,updated_at,last_written_by,url, andapi_url.
Patch site metadata
{
"password": "",
"write_password": "",
"ttl": "30d",
"write_policy": "org"
}Every field is optional, but write_policy must be present with owner or org to change it. link_access (public or private) is creator-only. Making an object private while either password is set is 409 link_access_conflict, with password_protected and write_password_protected in the body, unless the same request sends "password": "" and "write_password": "". Setting a password on a private object, or sending private with a password or write_policy: "org", is 400 link_access_conflict. Going private stores write_policy: "owner". Empty password clears the public read gate. Empty write_password clears guest write. Sending only write_policy or write_password does not require write access, but both are creator-only. Supplying ttl resets expiry from now and can revive an expired catalog row before purge wins. Response fields are id, slug, handle, url, password_protected, password, write_password_protected, write_password, expires_at, write_policy, link_access, and optional ttl. Phrase fields echo a newly set value and are null when the request cleared or did not change that door, so use the *_protected flags for current state.
Import and export
Import requires an existing writable site. It merges: archive paths replace matching site paths, and site paths absent from the archive remain. The merged result publishes in one commit. One wrapping directory is stripped; common OS junk is skipped. The 200 response’s written array lists the imported paths. With Prefer: respond-async, import stores the archive and returns 202 with a merge session (deployment_id, status_url, state, progress, next_action); prepare and commit it like a deployment. The session still merges rather than replaces.
Export requires a non-empty site and returns application/zip with an attachment filename based on the slug. It reads one published version. Import, export, and site copy accept at most 200 paths. The ZIP limits are independent: import input is capped by limits.zip_import_bytes (100 MiB by default), expanded import content by limits.zip_extracted_bytes (500 MiB by default), and site or account export by limits.zip_export_bytes (25 MiB by default). limits.zip_bytes remains as a compatibility field equal to the import cap. A site copy is bounded by remaining platform capacity instead. GET /v1/export zips every site and loose file keyed to the caller’s owner_id, with manifest.json. That archive is ownership, not involvement. Empty ownership is 400 empty_export. Over the cap is 400 too_many_files or 413 too_large with the totals; export per site instead. Share and write passwords are omitted. Private objects are included, and their manifest entries carry link_access: "private".
Site deployments
A deployment replaces a site’s complete file set in one commit. Publish a complete site update is the task recipe. Deployments arrived in Energon v1.10.0; a host has them when /v1/help lists limits.zip_import_bytes.
Create body, with exactly one of files or archive:
{
"expected_version": 4,
"idempotency_key": "1791400000000.6f1c2b9e-3c1d-4f7a-9a51-2f0d8f3b7c10",
"files": [
{ "path": "index.html", "size": 1234, "sha256": "<64 hex>", "content_type": "text/html" }
]
}| Field | Meaning |
|---|---|
expected_version | The site’s current content_generation. Commit fails with 409 deployment_conflict if the site changed after this. |
idempotency_key | Unix milliseconds, a dot, and a UUIDv4. The timestamp must be within the last hour at creation. Persist it with the intent and reuse it for retries. |
files | Every path to publish, at most 200, each with exact size, sha256, and content_type. Omitted paths are removed at commit. |
archive | { size, sha256, max_extracted_bytes?, max_files? } for a ZIP uploaded to /archive. Ceilings cannot exceed this Energon’s limits. |
Status and every operation response carry deployment_id, state (uploading, ready, committing, committed, aborted, expired, or failed), expected_version, expires_at (one hour after creation), status_url, progress { stored_files, missing_paths }, and next_action (upload, prepare, commit, or null). A committed session adds version_id, url, completed_at, and result_expires_at (seven days after commit). Repeating commit returns that receipt. Upload and prepare responses never mean the site is published.
The same key with a different intent is 409 idempotency_conflict. A changed base is 409 deployment_conflict. An expired session, receipt, or key window is 410.
Public site behavior
Site URLs do not change across deployments. Each request selects the version published when it starts and reports it in the X-Energon-Site-Version response header. That header is informational only; Energon has no request header or URL for reading an older version. Because an HTML page and its later asset requests select independently, an open page can mix versions across a commit until it is refreshed.
The returned root url ends in /. A public root serves index.html, otherwise index.md, otherwise a generated file listing. A specific .md path is rendered for Accept: text/html; ?raw=1 returns source and ?download=1 returns an attachment. Other stored types are served inline unless download is requested.
Loose-file routes
Route summary
| Method and path | Request | Success |
|---|---|---|
POST /v1/files | Raw upload, multipart upload, or duplicate | 201 metadata |
POST /v1/files/{id}/duplicate | JSON copy options, never bytes | 201 copy metadata |
GET /v1/files | List query | { files, total, next_cursor } |
GET /v1/files/{id} | Optional ?download=1 | Raw bytes |
PUT /v1/files/{id} | Raw or multipart bytes; optional rename | 200 replacement metadata |
PATCH /v1/files/{id} | JSON metadata patch | Updated file metadata |
DELETE /v1/files/{id} | None | { ok: true, deleted: id } |
The router also accepts an extra filename segment on authenticated GET|PUT|PATCH|DELETE /v1/files/{id}/{filename}, but identity and lookup use the id. From v1.14.0, the segment duplicate is reserved: POST copies the file, PUT, PATCH, and DELETE return 405 method_not_allowed, and GET still reads the file.
Create a loose file
Raw upload:
curl -fsS "$ENERGON_ORIGIN/v1/files" \
-H "Authorization: Bearer $ENERGON_TOKEN" \
-H "X-Filename: architecture.pdf" \
-H "X-Energon-TTL: 30d" \
-H "X-Energon-Write-Policy: owner" \
-H "Content-Type: application/pdf" \
--data-binary @architecture.pdfAccepted request forms:
| Form | Inputs |
|---|---|
| Raw bytes | X-Filename required; optional TTL, write-policy, set-password, set-write-password, and link-access headers. |
| Multipart | file required; optional password, write_password, ttl, write_policy, and link_access fields. The whole body is capped at 25 MB. |
| JSON duplicate | { duplicate_from, filename?, password?, write_password?, ttl?, write_policy?, link_access? }. |
| Header duplicate | X-Energon-Duplicate-From; optional X-Filename and policy headers; do not send Content-Type: application/json when using header policy. |
| Multipart duplicate | duplicate_from; optional filename, password, write_password, ttl, write_policy, and link_access; do not also include file. |
If Content-Type is application/json and X-Filename is present without a duplicate header, the JSON text is uploaded as literal file bytes.
A raw body can be as large as limits.file_bytes (100 MB by default) if the client sends Content-Length; without it, the cap is 25 MB. Multipart bodies are buffered by the Worker and stop at 25 MB, so send a larger file as a raw body with X-Filename. Cloudflare also rejects bodies over 100 MB on Free and Pro plans before the request reaches Energon. Over any of these caps is 413 too_large with a message naming the cap. A staged body that does not match its Content-Length is 400 bad_request.
Create 201 fields are url, api_url, id, handle, filename, size, content_type, password_protected, password, write_password_protected, write_password, ttl, expires_at, write_policy, link_access, and content_generation (always 1 for a new file or copy). Phrase fields contain the newly set value or null when that door was not set. List and detail also carry last_read_at.
A duplicate response adds created_by, duplicated: true, and duplicated_from; it otherwise has the create fields. The copy receives a new id and does not inherit source share password, write password, TTL, ownership, write policy, or link access.
From v1.14.0, POST /v1/files/{id}/duplicate copies a loose file without any upload form. The body is an optional JSON object with filename, password, write_password, ttl, write_policy, and link_access; an empty body copies with the source filename. The 201 response is the same as a duplicate_from copy. The route never stores request bytes: a body that is not a JSON object is 400 bad_json, and a form or multipart body is 415 bad_content_type. An unknown id is 404 file_not_found, and an expired source is 410. Anyone who can read the source through /v1 can copy it, so another account’s private file is 404 file_not_found. This is the copy operation in the gateway catalog, as files.duplicateFile.
Replace or rename a loose file
PUT replaces bytes under the existing id. X-Filename is optional: omit it to keep the filename or supply it to rename while replacing. Multipart PUT accepts a file field and uses that file’s name; the same 25 MB multipart cap applies. X-Energon-Set-Password can set or clear the share password during the replacement; setting one on a private file is 400 link_access_conflict.
PUT response fields are url, api_url, id, handle, filename, size, content_type, replaced: true, password_protected, password, and content_generation. password echoes a newly set phrase; it is null when the request cleared or did not change the password. PUT does not return or change TTL/write policy. PATCH does not rename.
From v1.11.0, every byte replacement adds one to the file’s content_generation; PATCH leaves it unchanged. To replace only if nobody else has, send X-Energon-Expected-Version with the generation you last read, or a multipart expected_version field (the header wins when both are sent). The value must be a positive integer, or the request is 400 bad_expected_version. A mismatch is 409 file_conflict with content_generation (current) and expected_version (yours) in the body, and nothing changes. The check runs again when the replacement commits, so a competing write that lands mid-upload still produces a conflict. Without the header, PUT is unconditional. The hub’s own PUT and a write-password guest PUT accept the same header. Replace only if nobody else has shows the read, edit, and PUT sequence.
Read a loose file
GET /v1/files/{id} returns raw bytes. ?download=1 changes Content-Disposition to attachment. Response headers include Content-Type, Content-Disposition, X-Content-Type-Options: nosniff, X-Energon-Write-Policy, X-Energon-Content-Generation, X-Energon-Link-Access, and Content-Length when available. Public file reads, including rendered Markdown, also send X-Energon-Content-Generation.
The public content URL in url includes the current filename. If a client uses the same id with a stale filename, Energon redirects to the current canonical url. Public Markdown follows the same HTML-versus-raw and download rules as site Markdown.
Patch loose-file metadata
PATCH accepts the same JSON fields as site PATCH, with the same link-access rules: optional password, write_password, ttl, write_policy, and link_access. It returns id, handle, filename, url, api_url, password_protected, password, write_password_protected, write_password, expires_at, write_policy, link_access, and optional ttl. Phrase fields echo a newly set value and are null when the request cleared or did not change that door. The response also carries the unchanged content_generation. write_password and link_access are creator-only.
Fresh concurrent write claims produce 409 file_busy. Stale claims can be reclaimed. PUT and DELETE conditionally commit against the claim so competing operations do not silently split catalog metadata from stored bytes.
An interrupted earlier write or uncertain deletion can leave a file blocked until an operator reconciles storage. Once that reservation needs an operator (its outcome is uncertain, its cleanup recorded an error, or its writer window passed), PUT, PATCH, DELETE, guest PUT, and upload grants for that file return 409 file_recovery_required (from v1.11.0; earlier releases return file_busy). It is not retryable. Reads keep working, and Admin Health lists the file. A live writer, or cleanup queued without an error, still returns retryable 409 file_busy. Both codes outrank file_conflict, because re-reading cannot unblock the file.
Guest write on the public content URL
Guest mutation is not a /v1 path. It uses the returned public content URL on CONTENT_ORIGIN with header X-Energon-Write-Password and a raw body. A private object refuses every guest method with 403 link_private. Read that host’s /llms.txt first. Let an outside agent update a URL is the task recipe.
| Public URL kind | Allowed guest methods | Scope |
|---|---|---|
Loose file /{handle}/f/{id}/{filename} | GET, PUT | PUT replaces that file only, including empty. DELETE is 405. Filename mismatch is 404, not a redirect. Stored Content-Type is kept. |
Site path /{handle}/s/{id}/{slug}/{path} | GET, PUT, DELETE | PUT adds or replaces one path. DELETE removes one path (200 { deleted: true, path }). New paths are capped at 200 files. |
Site directory /{handle}/s/{id}/{slug}/ | GET | PUT and DELETE are 405. Last-path DELETE keeps this URL at 200. |
Hub-origin PUT or DELETE of a content path is 307 to the content origin (not a 302 that becomes GET). Guest success JSON has a content-origin url and no api_url. Guest errors omit hub. Method is decided from URL kind before any secret check, so an illegal DELETE is 405 rather than a 401 writability oracle.
Upload grants
An upload grant lets a machine without an agent key upload one file once, or publish one approved site deployment. A token holder mints it on /v1; the uploading machine redeems it on the content origin. Let a machine upload without a token is the one-file recipe, and Publish a complete site update covers whole-site grants.
| Method and path | Auth | Request | Success |
|---|---|---|---|
POST /v1/grants | Agent key that can write the target | JSON target (exactly one: { "type": "new_file", "filename", "ttl"?, "write_policy"?, "password"?, "write_password"?, "link_access"? }, { "type": "file", "id", "expected_version"? }, or { "type": "site_path", "site_id", "path" }), optional expires_in (5m, 15m default, 30m, 1h), max_bytes, sha256 (hex) | 201 { id, secret, upload_url, method, header, scheme, expires_at, max_bytes, sha256, target, url, status_url }, no-store |
GET /v1/grants/{id} | Any agent key of the minting account | None | 200 { id, state, target, url, result_id, last_error, max_bytes, sha256, created_at, consumed_at, expires_at } |
PUT CONTENT_ORIGIN/_grants/{id} | Authorization: Bearer grant secret | Raw body only; Content-Length above 25 MB | 201 (new file or path) or 200 (replaced) { ok, created, url, id or site_id and path, size, content_type }; loose-file uploads add content_generation |
GET CONTENT_ORIGIN/_deployment-grants/{id} | Whole-site grant secret | None | Session status or committed receipt |
PUT CONTENT_ORIGIN/_deployment-grants/{id}/files/{path} | Whole-site grant secret | Declared raw bytes | Same as the /v1 session file upload |
PUT CONTENT_ORIGIN/_deployment-grants/{id}/archive | Whole-site grant secret | Declared ZIP bytes | Same as the /v1 session archive upload |
POST CONTENT_ORIGIN/_deployment-grants/{id}/prepare | Whole-site grant secret | No body | 202 progress or 200 ready |
POST CONTENT_ORIGIN/_deployment-grants/{id}/commit | Whole-site grant secret | No body | 200 committed receipt |
A whole-site grant uses target: { "type": "site_deployment", "deployment_id", "site_id"? } and binds an existing session owned by the minting account. Its mint response returns secret once (prefix deployment_grant_), expires_at, max_bytes, target, and status_url, upload_url (with a {path} placeholder), archive_url, prepare_url, and commit_url. expires_at is the earliest of expires_in, the session deadline, and the minting token’s expiry. The grant authorizes only the declared uploads, status, preparation, and commit of that session; it cannot change the intent or site settings, and settings headers are 400. Commit uses it up. Status reads and a repeated commit can recover the committed receipt for seven days, even after the grant expires, unless the minting token was revoked (410 grant_failed).
secret is shown once, starts with grant_, and never appears in upload_url. A new_file target’s password and write_password are echoed once inside the mint response’s target, never by GET /v1/grants/{id}, and the upload itself still rejects password headers. result_id on the status read and on 410 grant_used is the written loose file’s id, or the site id for a site_path target. expires_at is never later than the minting token’s own expiry. url on the mint response is the public URL the upload will change, or null for a new_file target. A new_file target with link_access: "private" creates a private file; adding a password to it is 400 link_access_conflict. A file or site_path target owned privately by another account is 404. state is unused, uploading, consumed, failed (with last_error), or expired; records are deleted a day after expiry.
From v1.11.0, a file target can carry expected_version, the file’s content_generation when you mint. Mint checks it (409 file_conflict if it is already stale) and the upload checks it again when it commits. If the file was replaced in between, the upload writes nothing and the grant ends failed with last_error: file_conflict. Other target types reject expected_version with 400 bad_target.
The upload runs as the minting account: it passes that account’s write policy and platform quota, and the account becomes the writer. Success publishes immediately and uses the grant up in the same commit as the catalog write. Metadata headers (X-Filename, set-password, TTL, write policy, duplicate, and X-Energon-Expected-Version) and multipart bodies are 400. The uploader cannot choose the content type: a new file’s type follows its filename and contents, and a replacement keeps its stored type. Only PUT is allowed; other methods, including OPTIONS, are 405 with Allow: PUT. A hub-origin PUT to /_grants/{id} is 404 naming the content-origin URL rather than a redirect. Revoking or expiring the minting token, or dropping its email from the allow-list, ends the grant.
List filters and pagination
GET /v1/sites and GET /v1/files share these query parameters. Unknown or malformed list keys on the hub URL are dropped so the catalog never 400s. Cleanup JSON rejects them with 400.
| Parameter | Values and default | Meaning |
|---|---|---|
scope | involved default, created, edited | Created by you or last written by you; created only; or other people’s objects you last edited. |
q | empty | Case-normalized substring of site slug or loose filename; %, _, and backslash are removed; at most 64 characters. |
created_by | empty | Lowercased exact creator-email filter, combined with scope. |
expires | empty, or never | Only objects with no expiry. Ignored when expires_before or expires_within is also present. |
expires_before | empty | ISO timestamp. Objects that never expire are excluded. Wins over expires_within when both are sent. |
expires_within | empty, 24h, or 7d | Objects that expire within that window, computed from the time of the request. Other values are ignored on lists and are 400 bad_query in cleanup JSON. |
updated_before | empty | ISO timestamp. Last written before this time. |
last_read_before | empty | ISO timestamp. Never-read objects and those whose last_read_at is older. Hub catalog listing drops this key; /admin and cleanup JSON keep it. |
min_size | empty | Bytes or a unit suffix such as 500mb. Sites use the sum of file sizes. |
changed_since_read | empty, or 1 | Objects whose updated_at is later than last_read_at; never-read objects count as changed. Other values are ignored. List-only: cleanup rejects it as 400 bad_target. |
sort | updated default, name, size, age, last_read | Newest first, name ascending, largest first, least recently written first, or never-read first and then oldest last_read_at. |
limit | 25; clamped to 1–50 | Page size. Invalid or missing values use 25. |
cursor | empty | Opaque keyset cursor returned as next_cursor. Must match the same sort. |
sites_cursor | empty | Site-specific cursor accepted in place of cursor. |
files_cursor | empty | File-specific cursor accepted in place of cursor. |
Do not parse or edit a cursor. Repeat the same filters and pass it back as cursor.
Site list items contain id, slug, handle, url, created_at, updated_at, created_by, last_written_by, file_count, size, password_protected, write_password_protected, expires_at, last_read_at, write_policy, link_access, content_generation, and version_id.
Loose-file list items contain id, handle, filename, url, api_url, size, content_type, created_at, created_by, updated_at, last_written_by, password_protected, write_password_protected, expires_at, last_read_at, write_policy, link_access, and content_generation.
last_read_at is a floor. Treat it as “read at least this recently”, never as unread since. Site reads always reach the Worker and are stamped at most about once an hour. Loose-file reads answered by the public edge cache do not stamp it.
Lists are scoped to the caller’s involvement view, and a private object appears only for the account that created it. A valid token can still fetch a known public object through its API route even when that object is absent from the list. Another account’s private object is 404 on every /v1 route.
Cleanup and owned export
POST /v1/cleanup retires work the caller can already write. Target explicit ids or the same list filters, including last_read_before and owner (still intersected with involvement). Mixing ids and filters is 400 bad_target. {} selects everything the actor is involved in, which is wider than ownership. Hub bulk cleanup does not send last_read_before.
{
"target": { "expires": "never", "kind": "files" },
"action": "set_ttl",
"ttl": "7d"
}Omit confirm for a dry run: { executed: false, matched, eligible, bytes, skipped, sample, confirm }. Resend the same body with that 32-hex confirm to execute. A stale hash is 409 cleanup_drift with a fresh preview. Actions are delete, set_ttl (requires ttl), and expire (fixed 30-minute grace; sending ttl is 400 bad_action). At most 100 eligible objects (413 cleanup_too_many). Not writable is skipped, never a batch 403. Another account’s private id is skipped as not_found, not not_writable. expire never moves expires_at later.
GET /v1/export downloads owned sites and loose files as {handle}-owned.zip. Layout is manifest.json, sites/{id}/…, files/{id}/{filename}. Each manifest entry carries id, the slug or filename, url, size, expires_at, write_policy, link_access, and archive_path. A private object is included; its url answers 403 link_private, so read it through /v1. Do this before POST /v1/cleanup with {} or retiring an account.
Admin routes need an admin key. They are documented for operators in Administer Energon: GET /v1/admin/health, POST /v1/admin/cleanup, GET /v1/admin/tokens, POST /v1/admin/tokens/revoke, GET /v1/admin/audit, plus quota recompute, sweep, and gate unlock. An account token gets 403 forbidden_admin.
Response and error conventions
Successful JSON responses use Content-Type: application/json; charset=utf-8. Binary GET/export responses stream bytes with the stored or generated content type.
Expected /v1 failures use this envelope:
{
"error": "machine_readable_code",
"message": "Human-readable recovery guidance.",
"hub": "https://hub.your-company.example/account"
}Some failures add fields such as accepted presets, configured limits, an expiry timestamp, a confirm hash, or a token page. Consumers should branch on HTTP status and error, show message, and preserve unknown extra fields.
An export or server-side copy can also return 500 export_failed or 500 copy_failed when catalog metadata points to missing source bytes. Re-upload the missing source path or file before retrying.
The public content surface has its own JSON gate response when an agent sends X-Energon-Password, X-Energon-Write-Password, or requests JSON: 401 { error: "password_required", message }. Write-password 401 names X-Energon-Write-Password and content /llms.txt. That response does not include the /v1 envelope’s hub field. Repeated failed guesses can instead return 429 { error: "rate_limited", message }; stop guessing and wait for the gate window. Unset write password on PUT or DELETE is 405, not 401. Every method on a private object’s public URL is 403 { error: "link_private", message } with Cache-Control: no-store, or a short HTML page for a browser; neither names the hub or /v1.
Whole-site grant routes use the same deployment codes plus 404 grant_invalid, 410 grant_expired, 410 grant_used, and 410 grant_failed, in no-store JSON without a hub field.
Upload grant redemption on the content origin has its own codes, in no-store JSON without a hub field: 404 grant_invalid (wrong id or secret, identical either way), 410 grant_used (adds the published url and result_id), 410 grant_expired, 410 grant_failed (adds reason, such as token_revoked, file_not_found, file_conflict, or file_recovery_required), 409 grant_busy (retry), 400 checksum_mismatch (the body does not match sha256; nothing was published), and 413 too_large (over max_bytes). Retryable failures leave the grant usable until it expires; grant_failed and grant_expired mean mint a new grant. After file_conflict, read the file again before minting; after file_recovery_required, a new grant fails too until an administrator reconciles the file.
Common mistakes
Never invent or reuse a token from stale chat history, and never infer url from PUBLIC_ORIGIN. POST /v1/sites always creates a new id. PUT does not create a missing object or extend TTL. A loose-file rename is PUT with X-Filename; PATCH changes only password, write password, TTL, write policy, or link access. Guest PUT is raw bytes on the public URL with X-Energon-Write-Password, not a /v1 call. An upload grant secret goes only in the Authorization header on its content-origin upload URL, never in a URL or on /v1. DELETE has no recycle bin.
Related guides
- Use Energon explains sites, loose files, and two-origin handoffs.
- Manage shared work turns these routes into everyday workflows, including hub cleanup and owned export.
- Administer Energon covers
/adminand/v1/admin. - Sharing, access, and expiry explains what tokens, link access, share passwords, write passwords, TTL, and write policy do.
- Keep work private to your account covers private link access end to end.
- Let an outside agent update a URL covers guest PUT and site-path DELETE.
- Publish a complete site update covers deployment sessions and whole-site grants.
- Let a machine upload without a token covers minting and redeeming single-use upload grants.
- Use Energon through a tool gateway covers gateway imports of this document, the well-known skill, and the upload helper.
- Configure Energon policy documents the settings surfaced by
/v1/help.
