The big picture
The wizard does two things in one pass: it authors a manifest and then applies it.
You are filling in a single declarative document —
greentic.env-manifest.v1 — that describes the desired wiring of one
environment (here, local): its trust root, which bundles run, how traffic routes to
them, which messaging channels exist, and which secrets they need.
That document is the durable artifact. It is written to
./local.env.json and meant to live in version control. Everything you typed can be
replayed headlessly later:
gtc-dev setup --answers local.env.json --non-interactive
After authoring, the wizard hands the manifest to the apply engine, which compares it to the live environment and makes only the changes needed (the “plan”).
How it fits together
One runtime, two bundles, two Telegram bots. The fields you enter wire up this picture.
0 Manifest file
Where the wizard writes the document it is building.
Manifest file default ./local.env.json
What: the path of the greentic.env-manifest.v1 JSON file this wizard
creates (or edits, if it already exists). Relative bundle paths inside it are resolved against
this file’s directory, so where it lives matters.
Why: this file is your environment definition. Commit it; re-apply it headlessly; diff it in PRs. The wizard is just a friendly front-end for writing it.
1 Trust root
The signing anchor that lets anything deploy at all.
Bootstrap the trust root? required
What: seeds the environment’s trust root with your local operator signing key. Greentic verifies every bundle/pack signature (DSSE / Ed25519) against this root.
Why: Greentic is deny-by-default — with no trust root, no bundle can be
staged or deployed. It is idempotent: do it once per environment; re-runs are a no-op
(operator key … already trusted). Leave it true the first time.
true) · manifest: "trust_root": "bootstrap"2 Bundles
Each row is one bundle deployment — a packaged digital worker — running in this
environment, plus how inbound traffic reaches it. You added two: legal and
accounting.
Bundle id required
What: the deployment’s natural key / handle, unique within the manifest.
Why: everything else refers to a bundle by this id — endpoints link to it, the router binds routes to it, the plan reports it. you → legal · accounting
Bundle path required
What: the local .gtbundle artifact to deploy (a SquashFS image of
packs + flows + config + SBOM). Relative paths resolve against the manifest file’s directory.
Why: this is what actually runs. The id names it; the path provides the bits.
Customer id optional
What: the billing principal this deployment is attributed to.
Why: required for non-local (cloud / commercial) environments so usage
can be metered and billed. For local it is irrelevant — leave it blank.
Config overrides (JSON) optional
What: last-mile per-pack config tweaks applied at deploy time —
{"<pack_id>": {"<key>": value}}.
Why: change a pack’s settings without rebuilding the bundle. Three-valued:
empty = leave untouched, {} = explicitly clear, a map = replace. You didn’t need it.
Route hosts optional
What: hostnames (comma-separated) that route to this bundle — host-based routing,
e.g. legal.bots.example.com.
Why: use it when bundles are separated by domain. This demo separates by path instead, so it’s blank.
Route path prefixes optional
What: the HTTP path prefix(es) that dispatch inbound traffic to this bundle,
each starting with / (e.g. /legal).
Why: this is how one runtime isolates many bundles.
/legal/webhook/telegram → the legal bundle; /accounting/… → the accounting
bundle. Without it the two bots would collide.
Route tenant optional (set with team)
What: the tenant this route binds to — the multi-tenancy scope (Global → Tenant → Team → User) threaded through every call.
Why it’s the crux of this demo: the tenant also scopes secret lookups. When the
Telegram provider asks the host for TELEGRAM_BOT_TOKEN, the host expands it to
secrets://local/<tenant>/_/messaging-telegram/telegram_bot_token.
Two bundles with the same tenant would resolve the same token. Giving one
legal and the other accounting is exactly what makes each bot use its own token.
Route team optional (set with tenant)
What: the team inside the tenant — the finer tenancy level.
Why: normally default, which the secret store canonicalises to _
(that’s the _ you see in the secret paths). Set it together with the tenant.
_ in the path)3 Messaging endpoints
Each row is a channel wired into the environment — here, one Telegram bot per department — and which bundle(s) its traffic is allowed to enter.
Endpoint name required
What: the endpoint’s manifest handle, its display name, and (on create) its provider instance identity, all in one.
Why: it’s the upsert key (together with provider type) — re-applying matches the same endpoint instead of creating a duplicate.
Provider type required
What: the provider class, e.g. messaging.telegram.bot.
Why: tells the runtime which provider component handles this endpoint’s ingest and egress (Telegram vs Slack vs Teams …).
Linked bundle ids optional
What: the bundle_ids this endpoint is allowed to dispatch into
(comma-separated).
Why: this is the admit gate / ACL. The legal endpoint admits only the
legal bundle, so a message that somehow reached the wrong path would be refused rather
than cross departments.
Welcome flow: bundle id / pack id / flow id optional · all 3 or none
What: a specific flow to run as a greeting when a user first contacts this endpoint.
Why: only if you want a fixed welcome screen wired at the endpoint level. Left
blank here — the bundle’s own on_message flow already answers.
Secret refs optional
What: extra secret references forwarded to the endpoint when it’s created (comma-separated).
Why: for endpoint-level credentials passed at create time (e.g. a webhook secret-token). The bot token itself is handled by the derived secrets step below, so this stays blank.
4 Secrets — derived, asked last
This is the part that changed. Instead of you typing secret paths up front, the wizard now figures out exactly which secrets your bundles need and asks only for the variable name.
How the wizard knew you needed two secrets
Each bundle ships provider packs that declare their secret requirements
(secret-requirements.json). The Telegram pack declares it needs a
telegram_bot_token. The wizard read that for each bundle, scoped it to the bundle’s
route tenant, and produced the exact path — no typing:
| Bundle | Derived secret path | You only entered |
|---|---|---|
| legal | legal/_/messaging-telegram/telegram_bot_token | TELEGRAM_LEGAL_BOT_TOKEN |
| accounting | accounting/_/messaging-telegram/telegram_bot_token | TELEGRAM_ACCOUNTING_BOT_TOKEN |
env var name name only — never the value
What: the name of the environment variable that holds each secret value.
The default suggestion (LEGAL_TELEGRAM_BOT_TOKEN) is just a hint; you typed your real
variable names.
Why a name and not the value: the secret value never goes into the manifest, so the manifest stays safe to commit. At apply time the engine reads the value from that variable (or the dev-store) and writes it into the secret store under the derived path.
Mind the default vs your real names
The suggested default is <TENANT>_<KEY> →
LEGAL_TELEGRAM_BOT_TOKEN. Your shell exports them the other way around
(TELEGRAM_LEGAL_BOT_TOKEN), so you correctly typed over the default. If you had pressed
Enter, apply would look for an unset variable and report it missing.
Edge cases — empty fields, host×path, all-or-nothing
The exact behaviour behind the optional fields: what an empty value does, which fields must be set together, how host and path combine, and why the welcome flow exists at all. These are the questions the cards above gloss over.
1 · Leaving a field empty
An empty string is treated identically to omitted — the wizard trims and drops blanks, so a stray space never sneaks into the manifest. For optional fields, "absent" almost always means "leave the live value alone": apply upserts, it never clears something just because you left the box blank. The exceptions are flagged below.
| Field | Empty means… |
|---|---|
public_base_url | keep whatever is already on the env (never cleared) |
customer_id | OK on local; apply fails on any non-local env — the billing principal is required there |
config_overrides | leave the deployment's existing overrides untouched (see §3 for {} vs a map) |
route_hosts | match any host |
route_path_prefixes | match any path (binds at the root, lowest specificity) |
route_tenant / route_team | no route binding at all → a fresh add gets an empty catch-all binding; a re-deploy leaves the existing binding untouched. Set both or neither. |
links | the endpoint admits no bundle through this field |
| welcome flow (×3) | no endpoint-level welcome flow (see §4) |
secret_refs | nothing extra forwarded when the endpoint is created (see §5) |
Two groups are all-or-nothing
Half-setting either group is a hard conversion error (set … together (or
none)), not a silent drop:
2 · Route hosts and route path — what if you set both?
They are ANDed. A request reaches a bundle only when its Host matches
one of the hosts and its path matches one of the prefixes. An empty list on either side means
"match anything" on that dimension.
| Route hosts | Route path prefixes | A request matches when… |
|---|---|---|
| (empty) | (empty) | always — catch-all / empty binding (any host, any path) |
| (empty) | /legal | path is under /legal, any host |
legal.example.com | (empty) | Host is legal.example.com, any path |
legal.example.com | /legal | Host is legal.example.com and path under /legal |
Among all host-matching routes, the longest matching path prefix wins (ties resolve
to the first deployment in env order, but the operator rejects ambiguous bindings at deploy time). Host
match is case-insensitive and port-stripped; a non-empty host list rejects a request that
carries no Host header. A tenant route with neither a host nor a path is rejected as
structurally unreachable. The demo uses path-only because the cloudflared tunnel hands every request to
one local port under whatever hostname it was assigned — separating by path is host-agnostic.
3 · Config overrides — three distinct values
| What you enter | Effect at deploy |
|---|---|
| (blank) | leave the deployment's existing overrides untouched |
{} | explicitly clear all overrides |
{"realbot":{"mode":"prod"}} | replace overrides with this <pack_id> → <key> → value map |
Anything that isn't a JSON object — bad JSON, or an array like [1,2] — makes
apply refuse with an error naming config_overrides. The point is to tune a pack's settings
without rebuilding the bundle.
4 · Welcome flow — all three, pointing at a linked bundle
The three IDs together locate exactly one flow: bundle_id = which
deployed bundle, pack_id = which pack inside that bundle, flow_id = which flow
inside that pack. Set all three or leave all blank — a partial trio is an error. The
bundle_id must be one of the endpoint's links (you can't greet with a
bundle the endpoint doesn't admit). Re-applying the same trio is a no-op.
Why would you set it? Is it standard? What about legacy?
Why: to pin a fixed entry/greeting flow at the channel level — e.g. a bot that should always open with a specific onboarding card, regardless of which message arrives. It overrides the bundle's own default routing for first contact.
Standard practice? No — it's optional and usually left blank. The bundle's own flow graph already handles incoming messages; you only reach for welcome flow when the endpoint should dictate the entry point rather than the bundle.
Legacy deployment: the legacy gtc start --bundle X path doesn't
create environment endpoints at all, so there is nothing to attach a welcome flow to — the bundle's
static-routes / message flow is the entry point. Welcome flow is a new-model feature:
it only exists because the routed, multi-bundle environment gives each channel a first-class endpoint
object.
5 · Secret refs — create-time only
What: reference strings pointing at already-stored secrets (e.g.
secrets://local/legal/_/messaging-telegram/…) — never the values. They're forwarded
to the endpoint only when it is first created.
What happens on a re-apply: if the endpoint already exists and your refs differ from
what's stored, apply emits a warning and leaves them untouched — there is no endpoint-update verb
yet — so get them right on the first apply. These are distinct from the derived per-bundle secrets
in section 4 (the bot token, resolved from each bundle's provider packs and scoped by route tenant). For
the Telegram demo the credential is that per-tenant token, so endpoint secret_refs
stay blank.
5 The plan & apply
With the manifest written, the engine diffs desired (your manifest) against the current environment and lists only the changes — then asks before doing anything.
- ensure-environment — create the
localenv (default env-pack bindings + trust-root seed). - bootstrap-trust-root — seed your operator signing key (the question 1 step).
- put-secret ×2 — write each bot token into the secret store, read from
$TELEGRAM_*_BOT_TOKEN. - deploy-bundle ×2 — stage + activate each bundle, bound to its path prefix and tenant.
- add-endpoint ×2 — register each Telegram endpoint.
- link-endpoint ×2 — link each endpoint to its bundle (the admit ACL).
Then the gate: apply 10 change(s) to env `local`? [y/N]. The whole
thing is idempotent — re-running the same manifest shows no-op for anything already in
place (you saw changed:10, no_op:0 because this env was fresh; a second run would flip that).
The trailing JSON is the machine-readable result (changed / no_op /
verify) for scripts and CI.
Why a plan-then-confirm shape
Plan and execute are deliberately separate: you see precisely what will change
(create vs put vs no-op) before any mutation, and the same plan path powers a non-interactive
--dry-run convergence check in CI.
That startup warning
“Greentic toolchain release context is not installed for channel 'dev'”
Harmless for this flow. That context is the pinned compiler/WASM toolchain used to
build components. Authoring and applying an environment manifest doesn’t need it, so the
wizard runs fine. Run gtc-dev install only when you start building components/packs locally.