Skip to Content
Use EnergonUse Energon through a tool gateway

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 /v1 and the grant secret on the content origin’s /_grants and /_deployment-grants URLs.

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:

DocumentURLPurpose
OpenAPI{hub origin}/v1/openapi.jsonEvery 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.jsonThis 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 as executor-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 /keys ends the gateway’s access and every grant it minted.
  • Pick a token lifetime you will remember to renew. An expired token returns 401 token_expired and 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 -g

This 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" --help

Replace 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 curl with 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 or curl, 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

  1. On the machine, read the file’s size and hash:

    python3 scripts/energon_publish.py inspect report.pdf
  2. Through the gateway, call mintGrant with 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>}
  3. 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.

  1. On the machine, build the archive:

    python3 scripts/energon_publish.py inspect ./dist

    The output includes archive (size and sha256), idempotency_key, and files. 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. Review files before minting, with the person who asked when it matters.

  2. Through the gateway, call createSite for a new site, or getSite for an existing one, and keep content_generation.

  3. Call createDeployment with {"expected_version":<content_generation>,"idempotency_key":"<from inspect>","archive":<archive from inspect>} and keep deployment_id.

  4. Call mintGrant with {"target":{"type":"site_deployment","deployment_id":"<deployment_id>"}}.

  5. 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-folder command. It reads the deployment status and continues from upload, prepare, or commit.
  • Lost or expired grant. Call getDeployment through the gateway. While the deployment is still uploading or ready, mint a new site_deployment grant for the same deployment_id and 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 inspect again 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 PATH or --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/help reports, 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: HUB pins the import to the hub, so the token can only reach the hub. The /_deployment-grants operations declare the content origin and are skipped as multiple_hosts; the machine calls them with the helper.
  • methods fills the document’s bearerAuth scheme from the account’s token field. Nothing fills deploymentGrant, so no operation that needs a grant secret is exposed through the gateway.
  • withApprovals asks 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.json on 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 copy npx skills add installed.

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.

Last updated on