Skip to content

Durable Fly Deploy on a Steward

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/tutorials/fly-durable-deploy/.
In `examples/fly-durable-deploy`, run `npm install` and export `FLY_FLAPS_BASE_URL=http://localhost:4280`. Run `npx chant run fly-durable-deploy --no-record` twice against mudflaps.
Show me the output of both runs and the Machine state mudflaps reports after each.
Run `npm run build:fountain` and `npm run lint`, and show me the findings. Do not post the Op to a fountain steward with `--on fountain`.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The fly-durable-deploy example runs a Fly Machines deploy on a schedule, unattended. The op is scheduled on a fountain steward: one teammate, one persistent machine, one thread carrying every deploy this app has had.

Where fly-deploy-rollback uses a Sprite checkpoint as its recovery boundary, durability here comes from two properties that have nothing to do with a workflow engine.

The machine persists. The steward’s sandbox is sandbox_mode: persistent, so the checkout, the npm cache and the installed chant survive a turn ending. A turn is a command on a computer that was already there, not a container booted to run one deploy and thrown away.

The op converges. Build serializes src/infra.ts to the flaps plan; Deploy applies that plan to the Machines API and waits each machine to started. Applying it twice reaches the same App and the same Machine. So a turn that dies halfway leaves the estate wherever it got to, and the next run walks the same steps against that state and finishes. Nothing is resumed, because a convergent op has the same shape from every starting point.

ops/fly-durable-deploy.op.ts is two phases and a cron:

import { Op, phase, build } from "@intentius/chant/op";
import { flyApplyStep } from "@intentius/chant-lexicon-fly";
export default Op({
name: "fly-durable-deploy",
overview: "Deploy the Fly App + Machine, convergently, on a cadence",
schedule: { cron: "*/30 * * * *" },
phases: [
phase("Build", [build(".", { script: "build:fly" })]),
phase("Deploy", [flyApplyStep("dist/fly.json")]),
],
});

schedule is data on the Op, not a resource. The flaps endpoint comes from FLY_FLAPS_BASE_URL, which points at local mudflaps offline and at real Fly when unset.

ops/fountain.ts declares the machine that runs it. The Steward composite returns the Agent, the Teammate, and one Schedule per op that carries a cron (plus a Webhook when you declare one); the Environment and the Vault it reads are declared in the same file and emitted alongside them:

export const { agent, teammate, schedules } = Steward({
name: "fly-steward",
environment: toolchain,
vault: flyCreds,
ops: [flyDurableDeploy],
});

toolchain is the Environment the sandbox is provisioned from: the estate repo mounted at /workspace/estate, a setup script that installs chant, and networking_type: "limited" with an allowlist narrow enough to review (api.machines.dev, github.com, registry.npmjs.org). flyCreds is the Vault that holds FLY_API_TOKEN, which is set in fountain and never in the repo. The Steward reads the op’s name and its cron off the declaration, so the cadence is written once, next to the op it paces.

chant.config.ts declares the fountain profile the CLI reads:

fountain: {
profiles: { demo: { endpoint: "https://fountain.inevitable.fyi", token: { env: "FOUNTAIN_TOKEN" }, team: "fly-steward" } },
defaultProfile: "demo",
}
  • Node.js and npm.
  • Docker (for the mudflaps emulator).
  • A fountain instance and a token, for the steward half. The local half needs neither.

The op runs here, in-process, with no fountain at all. Same steps, one observation instead of a cadence:

Terminal window
docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
cd examples/fly-durable-deploy
npm install
export FLY_FLAPS_BASE_URL=http://localhost:4280
chant run fly-durable-deploy
[phase] Build
✓ chantBuild(script=build:fly) 3.1s
[phase] Deploy
✓ flyApply(plan=dist/fly.json) 6.8s
Op "fly-durable-deploy" completed in 9.9s

The App and Machine are now in mudflaps:

Terminal window
curl -s http://localhost:4280/v1/apps/fly-durable-demo/machines | jq '.[].state'
# "started"

Now run it a second time.

Terminal window
chant run fly-durable-deploy

The second run is the point. It reports the same steps and reaches the same App and Machine rather than deploying a second one. That is the property the schedule leans on, and it is why nothing here has to be resumed.

Terminal window
npm run build:fountain # dist/fountain.yaml
export FOUNTAIN_TOKEN=... # the variable chant.config.ts names
npm run lint # FTN001, FTN010, FTN020..FTN023
chant run fly-durable-deploy --on fountain

chant run … --on fountain posts the command line chant run fly-durable-deploy to the steward’s thread, tails the conversation’s event stream, and hands back a record in the shape a local run would have written. Open the conversation in fountain’s UI and every turn is one command somebody could have typed, so scrolling the thread is scrolling this app’s deploy history.

If the steward is mid-turn the post is refused rather than queued:

fountain runtime: the steward "fly-steward" is running another op
(https://fountain.inevitable.fyi/conversations/c_01J…). A teammate runs one
turn at a time; wait for it to finish and run this again.

A teammate runs one turn at a time, which is what keeps two deploys off one checkout, and it is also why the schedule is in-thread: a fire that lands mid-turn is dropped, and the next one re-applies everything anyway.

Point the sandbox at your own estate with the build parameter:

Terminal window
chant build ops --lexicon fountain -o dist/fountain.yaml \
--param repoUrl=https://github.com/you/your-estate

Drop the emulator override and give it a token. The op does not change:

Terminal window
docker rm -f mudflaps
unset FLY_FLAPS_BASE_URL # flyApply falls through to real Fly
export FLY_API_TOKEN=...
chant run fly-durable-deploy

On the steward, the token lives in the Vault instead, and the op is unchanged.

chant