Provider lifecycle
A provider is created deliberately, once, with a name a person chose. Everything ephemeral attaches to it: a daemon never creates a provider, it claims one.
There are two ways to get a working provider, and they end in the same place —
a live worker whose providerName is your provider's name.
| What it does | No terraform? | |
|---|---|---|
| Provision | Scaffold terraform into .dream/providers/<name>/, resolve the decisions, plan, apply, then put a daemon on the result | — |
| Adopt | SSH to a cluster that already exists and start a daemon there under a supervisor | --provider ssh |
The CLI embeds no model
There is no chat endpoint here, no agent framework, and nothing that calls a language model. The CLI is a tool an agent drives. It does as much as it can without guessing, then stops and reports — in prose for a human, in JSON for an agent — where it is and what the next action is. Something else performs that action and re-runs the tool.
Three consequences, each a hard rule:
- It never blocks on a terminal. An agent has no TTY. "Needs input" is an exit state carrying a next action, never a prompt.
- Every state names its next action, in prose and in
--json.nextActionis a required, nullable field, so an agent reads one key. createnever applies. Applying spends money and is its own verb.
The loop, for an agent
This is the whole interface. Follow it verbatim.
Without it an agent loops on an unresolvable decision forever, burning tokens
and cloud API calls against a question only a person can answer. Two identical
blocking[] key sets in a row means stop and report, not "try again".
Exit code and status answer different questions
The single most important thing to get right when scripting this:
| Answers | Values | |
|---|---|---|
| exit code | did the tool work? | 0 = coherent · 1 = error |
status | where are we? | the ten-state union below |
nextAction | what happens next? | an object, or null when done |
needs_input exits 0. It is not a failure — the tool did exactly its job,
which is to find out what it cannot decide and say so. An agent driving under
set -e, or a Makefile, must not die because the tool successfully reported
a question. Exit 1 is reserved for the tool could not do its job: bad flags,
no auth, no binary, a terraform error, an unreachable host.
This is not new. dreamlake provider delete without --yes on a non-TTY
already cancels and returns 0 — "I deliberately did not do the thing" has
always been a success in this CLI.
The ten states
Read the table as verb × observed world → status.
| From | Verb | Trigger | → status | rc |
|---|---|---|---|---|
| — | create | dir absent, vendor known, no decision blocks | scaffolded | 0 |
| — | create | dir absent, vendor known, decisions unresolved | needs_input | 0 |
| — | create | dir absent, --provider names no vendor | error | 1 |
scaffolded/needs_input | create --continue (or a bare re-run) | decisions now all resolved | scaffolded | 0 |
needs_input | create --continue | still unresolved | needs_input | 0 |
| any | create | dir exists, same vendor, no file would change | exists | 0 |
| any | create | dir exists, different vendor | error | 1 |
scaffolded | plan | init + validate + plan all clean | ready | 0 |
scaffolded | plan | plan surfaces a missing/invalid variable | needs_input | 0 |
scaffolded | plan | terraform errors for any other reason | error | 1 |
ready | apply --yes | apply exits 0 | applied | 0 |
ready | apply without --yes | — | needs_input | 0 |
ready/applying | apply --yes | state lock held, or apply interrupted | applying | 0 |
ready | apply --yes | terraform errors for any other reason | error | 1 |
applied | connect | ssh ok, supervisor started, claim observed | connected | 0 |
applied | connect | ssh ok, daemon started, claim not yet observed | connecting | 0 |
applied | connect | no --host and none derivable from outputs | needs_input | 0 |
connected | connect | a live worker already claims it | exists | 0 |
| any | status | recomputed from the world | any of the above | 0 |
applied | status | zero live claimants | unclaimed | 0 |
| any | any | no auth / no binary / unwritable dir / ssh refused | error | 1 |
scaffolded → needs_input and back is a cycle, and that is the point — it
is the loop the agent walks. Everything else is a DAG.
error is not a dead end. Every terraform failure — init, validate,
plan or apply — carries a real nextAction: edit_then_continue pointing
at the terraform directory, with dreamlake provider plan -n <name> as the
command. That is the one state where an agent most needs a next step, and
nextAction: null there would tell it the run is over.
The recovery command is always plan, never apply. plan reads and
costs nothing; if it comes back clean, its nextAction is the apply. A
failed apply that handed back apply --yes would turn a broken config into a
retry loop that spends money on every turn.
applying is the only state whose next action is "run the same command
again". Terraform owns that recovery and this CLI does not reimplement it: it
detects .terraform.tfstate.lock.info, or an apply that left a partial state,
and says so.
needs_input — the state this exists for
The prose form:
The same run, with --json. This is the payload an agent branches on:
Every field, once
Filling observed needs a live account probe. When credentials are not
available the field is simply omitted and degraded carries "no-probe".
An agent that reads a missing observed as "there are no regions in use" will
confidently pick the wrong one. Check degraded first.
The other states, in prose
--yes is baked into the printed command on purpose. There is no
confirmation prompt anywhere on this path; the guard is that you have to type
a different command than the one you just ran.
connected is the only status whose nextAction is null. All of these
exit 0. Zero bridges for a provider is a normal state, not an error — it must
never render as a failure.
exists carries files: []. The empty write list is the proof it was a
no-op, and it is exactly what a test asserts.
Idempotence
Re-running is the resume. --continue is sugar over that, not a second
code path: without it, a directory that already exists is reported
(exists); with it, the existing directory is expected and the command goes
straight to the decision scan.
Two refusals you can rely on:
-
A second
createwith identical inputs →exists, rc 0,files: [], and no file's mtime moves. -
A second
createwith a different--provider→error, rc 1. It never merges and never overwrites:
Where the state lives: nowhere
The phase is derived on every invocation, never stored. Four observations, in this order:
- Does
.dream/providers/<name>/exist, and what does itsprovider.jsonsay it is — identity and template provenance, written once, never a phase. - The decision scan over
terraform/terraform.tfvarsagainstTEMPLATE.json'sdecisions(oradopt.jsonfor the ssh vendor) →blocking[]. terraform show -json, plus the presence ofterraform.tfstateand any lock file →appliedvs not,applyingor not.- The control plane's live workers, filtered to
providerName == <name>→connected/unclaimed.
A stored phase can disagree with the world; a derived one cannot, and it
cannot lie about a terraform apply someone ran by hand in that directory.
The directory
.dream/ goes beside the nearest .dreamrc walking up from cwd; with no
.dreamrc anywhere, beside cwd. The chosen root is printed before anything
is written. --dream-dir <path> overrides it.
The generated .gitignore covers providers/*/terraform/.terraform/,
providers/*/terraform/terraform.tfstate* and providers/*/terraform/*.tfplan
— so the provider is reviewable in a PR while state and the plugin cache stay
out of git.
Adopting a cluster over SSH
--provider ssh selects a vendor with no terraform at all. create writes
adopt.json; connect does the work, in this order:
- Check the claim first. If a live worker already claims the provider →
exists, rc 0, no ssh at all. Idempotence before action. ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-newto probe.BatchMode=yesis the never-block-on-a-TTY rule applied to ssh: it can never sit at a password prompt. A refused key is a sentence plusnextAction: {"kind":"shell","command":"ssh-copy-id you@ops.internal"}.- Install the daemon.
- Start it under the best supervisor the host actually has, and say which.
| Available on the host | What connect does | status |
|---|---|---|
systemctl --user + loginctl enable-linger | user unit, Restart=always | connected |
systemd + passwordless sudo | system unit, Restart=always | connected |
| neither | tmux new-session -d -s nymph-<name> | connected, degraded: ["no-supervisor"] |
tmux is for attaching, not for supervising
tmux is chosen over nohup and screen for one reason: the session is
reattachable, which is how you debug a daemon that is misbehaving. nohup
gives you a log file and no way in. But reattachable is not supervised, and
the prose says so plainly:
The SSH connection is never the supervisor. connect returns as soon as
tmux new-session -d (or systemctl start) does; it holds no socket. Killing
the CLI does not touch the daemon, and there is no long-lived --wait on this
path.
Templates are a starting point, not a default
Vendor templates ship inside the CLI, one directory per vendor, each with a
TEMPLATE.json manifest whose decisions[] is blocking[], pre-written:
The decision scan is: for each decision, read var out of file; unset, still
the placeholder, or failing variables.tf's own validation block → a
Blocking. That is the whole algorithm, and it is offline. A missing
account probe never fails a command; it only leaves observed empty.
They are derived from a real, working setup — which means a real VPC CIDR, real
IAM names, a real project prefix. Account-specific values are deleted, not
defaulted: they ship as a decisions[] entry with no default and a commented-out
key in terraform.tfvars. Read ADAPT.md in the scaffolded directory, which
says per decision what to look at in your account and how to choose, before
picking values.
provider.json records templateVersion, so provider status can report
template: kube v1 (v2 available) and offer a command. It never edits files
under you.
What this page does not cover
The five CRUD verbs on the provider row in DreamLake — create from a file,
list, show, update, delete — are a different surface, documented in
Lakeshore resources. The lifecycle verbs on this page are leaves
on that same provider group; both keep working.