Run this Energon behind a tool gateway when a fleet of machines should publish without each one holding an agent key. The gateway keeps the token and makes the control-plane calls; each machine sends its own bytes straight to the content origin with a short-lived upload grant. This split matters because a gateway tool call cannot carry a local file efficiently, and copying an account token onto every machine widens the damage of one leak.
Use Energon through a tool gateway
The well-known skill, the upload helper, and the readable deployment operation names arrived in Energon v1.12.0 . Check GET /v1/help on your Energon: if it has no agent_skills_url field, this host runs an older release.
How the pieces fit
Three parties take part, and each holds only what it needs:
- The gateway imports this Energon’s OpenAPI document as tools and holds the agent key. It creates sites and deployments, mints upload grants, and reads status. It never handles file bytes.
- The machine runs the agent and the bundled helper,
energon_publish.py. It holds the files and, for a few minutes, a grant secret. It never holds the agent key. - This Energon checks the token on the hub origin’s
/v1and the grant secret on the content origin’s/_grantsand/_deployment-grantsURLs.
The gateway is the credential and orchestration boundary, not a byte proxy. Executor, for example, passes binary request bodies to OpenAPI tools as base64 strings, so pushing a site through a tool call would inflate it and route every byte through the gateway. The grant path keeps uploads on the content origin, where upload grants and whole-site grants already work.
What this Energon serves for gateways
Each Energon serves two public documents a gateway can load by URL, with no access to the deployment repository:
| Document | URL | Purpose |
|---|---|---|
| OpenAPI | {hub origin}/v1/openapi.json | Every operation has a readable operationId and a tag, so tool names come out as sites.createSite, deployments.createDeployment, or grants.mintGrant. |
| Agent skill | {content origin}/.well-known/agent-skills/index.json | This Energon’s rendered publish skill, in the agent-skills discovery v0.1.0 index format, with SKILL.md, references/api.md, and scripts/energon_publish.py. |
In the served OpenAPI document, the five /_deployment-grants/* paths carry their own servers entry set to the content origin, because only the content origin redeems grant secrets. A gateway that pins one origin per import skips them. Executor does exactly that: it imports the hub operations and leaves out the /_deployment-grants operations as multiple_hosts. That is the intended result, because the machine calls those URLs itself with the grant secret. When this Energon has no dedicated content origin, such as a local single-origin run, the path-level servers entries are omitted.
GET /v1/help reports the skill location as agent_skills_url. It is null when the operator turned the skill off with AGENT_SKILLS_DISCOVERY or when CONTENT_ORIGIN is invalid; see Configure Energon policy.
export ENERGON_ORIGIN="https://hub.your-company.example"
curl -fsS "$ENERGON_ORIGIN/v1/help" | jq '{content_origin, agent_skills_url}'
curl -fsS "https://content.your-company.example/.well-known/agent-skills/index.json" | jq .1. Give the gateway its own token
An Energon agent key has no read-only or per-object scopes. It acts as its account on every /v1 route that account may use, including minting grants, deleting objects it can write, and revoking itself. Whatever the gateway can reach, anyone who controls the gateway can reach.
- Create a dedicated agent key for the gateway at
/keys, with a label that names it, such asexecutor-gateway. If your Access policy allows a separate identity for the gateway, create it from that account so its objects and audit trail stay apart from a person’s. - Never give the gateway an admin key. Admin keys last at most seven days and reach
/v1/admin. - Store the token only in the gateway’s credential store. Revoking it on
/keysends the gateway’s access and every grant it minted. - Pick a token lifetime you will remember to renew. An expired token returns
401 token_expiredand cannot be extended; create a replacement and update the gateway’s account.
2. Install the skill on each machine
Install this Energon’s skill on every machine that runs an agent, so the helper is on disk next to SKILL.md. Use the content origin from /v1/help:
npx skills add https://content.your-company.example -gThis reads the well-known index and needs no GitHub access. The installed skill names this Energon’s hub origin, token environment variable, and token prefix. It holds no credentials. An installed copy does not update itself when this Energon upgrades; install it again after an upgrade.
A gateway can also serve the same skill to agents, but a gateway-loaded skill arrives as text with no files on disk. In that case the skill tells the agent to download the helper from agent_skills_url into a temporary directory and run that copy:
AGENT_SKILLS_URL=$(curl -fsS "$ENERGON_ORIGIN/v1/help" | jq -r .agent_skills_url)
dir=$(mktemp -d)
curl -fsS "${AGENT_SKILLS_URL}your-energon/scripts/energon_publish.py" -o "$dir/energon_publish.py"
python3 "$dir/energon_publish.py" --helpReplace your-energon with the skill name from the index. The helper needs only Python 3 and its standard library. Always run it as python3 energon_publish.py; the file is not marked executable.
3. How an agent behaves in gateway mode
The skill switches to gateway mode when a tool gateway loaded it or when this Energon’s operations are reachable as tools, including through a code-mode gateway’s tool search for mintGrant or createDeployment. In gateway mode the agent:
- Uses the gateway’s tools wherever the skill shows
curlwith a token. - Never asks for, looks for, or provisions a token on the machine, and skips the connect flow.
- Calls grant URLs (
upload_url,status_url,archive_url,prepare_url,commit_url) from the machine with the helper orcurl, never through the gateway, even if the gateway lists the grant operations as tools. - Passes a mint response to the helper on stdin or in a mode-600 temporary file, never as a command-line flag or in a URL.
A machine with no tools and no token that holds only a grant handed to it does not need the skill at all. It can follow the content origin’s /llms.txt.
4. Publish one file
-
On the machine, read the file’s size and hash:
python3 scripts/energon_publish.py inspect report.pdf -
Through the gateway, call
mintGrantwith the returned values. To replace an existing file instead, use{"type":"file","id":"<id>","expected_version":<content_generation>}as the target.{"target":{"type":"new_file","filename":"report.pdf","ttl":"7d"},"sha256":"<sha256>","max_bytes":<size>} -
On the machine, hand the mint response to the helper on stdin:
python3 scripts/energon_publish.py publish-file report.pdf --grant-file - <<'EOF' <mint response JSON from mintGrant> EOF
The helper checks that upload_url is on the content origin named by /v1/help, uploads the raw bytes, and prints the published url. If the grant was already used, it reports the published URL instead of failing. A grant fixes its target, so --file-id, --expected-version, and --filename are refused in grant mode.
5. Publish a folder
A folder publishes as one ZIP deployment that replaces the whole site in a single commit.
-
On the machine, build the archive:
python3 scripts/energon_publish.py inspect ./distThe output includes
archive(sizeandsha256),idempotency_key, andfiles. Hidden files and directories (any path segment starting with.) are left out unless you pass--include-hidden. Symlinks are always left out. A folder over 200 files is refused before any request. Reviewfilesbefore minting, with the person who asked when it matters. -
Through the gateway, call
createSitefor a new site, orgetSitefor an existing one, and keepcontent_generation. -
Call
createDeploymentwith{"expected_version":<content_generation>,"idempotency_key":"<from inspect>","archive":<archive from inspect>}and keepdeployment_id. -
Call
mintGrantwith{"target":{"type":"site_deployment","deployment_id":"<deployment_id>"}}. -
On the machine, publish with the mint response on stdin:
python3 scripts/energon_publish.py publish-folder ./dist --grant-file - <<'EOF' <mint response JSON from mintGrant> EOF
The helper uploads the persisted archive, calls prepare until the deployment is ready, commits, and prints url and version_id. If the folder changed after inspect, it still publishes the archive inspect built and warns you; run inspect --restart first to publish the current content.
Resume and expiry
The helper keeps the archive and a small state file for each folder so an interrupted publish resumes with the same intent and never trips idempotency_conflict.
- Interrupted run. Rerun the same
publish-foldercommand. It reads the deployment status and continues from upload, prepare, or commit. - Lost or expired grant. Call
getDeploymentthrough the gateway. While the deployment is stilluploadingorready, mint a newsite_deploymentgrant for the samedeployment_idand rerun. Do not create a second deployment for the same release. - Expired deployment. Deployments last one hour. When the helper reports that the deployment expired, run
inspect --restart, create a new deployment with the new key and archive, mint a new grant, and publish again. - Stale key before a deployment exists. A new deployment needs an idempotency key from the last hour. Running
inspectagain refreshes an old unused key.
State lives under ~/.cache/energon-publish (or $XDG_CACHE_HOME/energon-publish; --state-dir moves it). The directory is owner-only, and the archive and state files are mode 600. The state holds the key, site and deployment ids, archive path, and status URL, never a token or grant secret. It is deleted after a successful commit, files older than 24 hours are removed, and state written on another host is ignored. Keep it out of synced folders.
The helper retries 409 grant_busy, 409 file_busy, and server errors up to three times. It retries a lost response only when the request is safe to repeat; it never retries creating a loose file, because a second attempt could publish a duplicate.
Keep secrets on the right path
- The helper takes a token only from the token environment variable the skill names, and a grant only from
--grant-file PATHor--grant-file -for stdin. It refuses any argument that looks like a token or grant secret. - It sends a grant secret only to URLs on the content origin that
/v1/helpreports, and it does not follow redirects. - JSON results go to stdout and progress to stderr; neither contains the secret.
- The mint response passes through the gateway and the agent’s context. A grant is bound to one target, works for at most an hour, and dies with the token that minted it, which is why it is safe to hand to a machine and the agent key is not.
Do not copy the gateway’s agent key onto machines to make uploads simpler. Every machine that holds it can do anything that account can do on /v1, and revoking it stops the gateway too. Hand machines grants instead.
Example: a minimal Executor app
This example targets Executor 2.0.0-beta.8, whose app framework is the apps package 0.0.1-beta.19. It follows the APIs that release documents in its app-authoring guide (liveOpenapiRouter, accountRouter, withApprovals, wellKnownSkills, and provider secrets). It has not been deployed as part of these docs, so check names against your Executor release with framework.describe before relying on it. Replace both example origins with the values from your /v1/help.
package.json:
{
"dependencies": {
"apps": "0.0.1-beta.19"
}
}provider.ts declares the agent key as an account secret. With hosts, app code sees only an opaque handle, and Executor substitutes the real token only on requests to the hub host:
import { decodeJson, defineProvider, object, secrets, string } from "apps";
export const energon = defineProvider({
name: "Energon",
hosts: ["hub.your-company.example"],
auth: {
apiKey: secrets({
label: "Energon API token",
fields: object({ token: string({ minLength: 1 }) }),
}),
},
async health({ account, fetch, signal }) {
const response = await fetch("https://hub.your-company.example/v1/whoami", {
signal,
headers: { Authorization: `Bearer ${account.fields.token}` },
});
const me = await decodeJson(response, object({ email: string(), label: string() }));
return { accountInfo: { email: me.email, displayName: me.label } };
},
});index.ts imports the live OpenAPI document as tools for the selected account and loads this Energon’s skill from the content origin:
import { accountRouter, defineApp, dynamicSkills, withApprovals } from "apps";
import { always } from "apps/operations/approval";
import { liveOpenapiRouter } from "apps/openapi";
import { wellKnownSkills } from "apps/skills";
import { energon } from "./provider.ts";
const HUB = "https://hub.your-company.example";
const CONTENT = "https://content.your-company.example";
export default defineApp(
{ accounts: { energon: energon.many() } },
async ({ accounts, cache, fetch, signal }) => ({
tools: await accountRouter(
accounts.energon,
async (account) =>
withApprovals(
liveOpenapiRouter({
source: { url: `${HUB}/v1/openapi.json` },
allowedOrigin: HUB,
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
deploymentGrant: { type: "http", scheme: "bearer" },
},
methods: {
apiKey: [{ scheme: "bearerAuth", field: "token", part: "value", prefix: "" }],
},
oauth: [],
cache,
fetch,
signal,
account,
}),
(tool) => (tool.kind === "mutation" ? always() : undefined),
),
{ signal },
),
dynamicSkills: dynamicSkills({
list: () => wellKnownSkills({ url: CONTENT, fetch, signal, cache }),
}),
}),
);What each part does:
allowedOrigin: HUBpins the import to the hub, so the token can only reach the hub. The/_deployment-grantsoperations declare the content origin and are skipped asmultiple_hosts; the machine calls them with the helper.methodsfills the document’sbearerAuthscheme from the account’stokenfield. Nothing fillsdeploymentGrant, so no operation that needs a grant secret is exposed through the gateway.withApprovalsasks for approval before every mutation, which is what Executor’s authoring guide recommends for imported OpenAPI tools. Loosen the policy for read-mostly fleets only after you decide which mutations a gateway agent may run unattended.wellKnownSkills({ url: CONTENT })reads/.well-known/agent-skills/index.jsonon the content origin and serves the skill’s files as text. Executor does not run or write the helper; agents download it as shown in step 2, or use the copynpx skills addinstalled.
Executor names imported tools after each operation’s first tag and its operationId, such as deployments.createDeployment and grants.mintGrant. Search the gateway for the exact names. Hub upload operations such as uploadDeploymentArchive or putFile can still appear as tools; the skill tells agents to use grants for local bytes instead.
Common mistakes
Do not call /_deployment-grants or /_grants URLs through the gateway, and do not add the content origin to the gateway’s allowed origin to make them appear. Grant secrets belong on the machine that holds the bytes, and the agent key never needs to reach the content origin.
Do not import the OpenAPI document once and pin operation names you copied from Energon v1.11.0 or earlier. v1.12.0 renamed twelve deployment and deployment-grant operations, such as postVSitesIdDeployments to createDeployment. Reimport from the live /v1/openapi.json after each upgrade. The full list is in HTTP API.
For the protocols underneath, read Let a machine upload without a token, Publish a complete site update, and Connect an agent.