Skip to content

11. State, Configuration, Runtime Values, and Secrets

An API address, a retry limit, an OpenAI key, the time of the last successful synchronization, and a ten-gigabyte reference dataset are all “values.” They are not the same kind of state.

Putting all five into global variables would be convenient for a few minutes and expensive for the rest of the application’s life. Their owners, lifetimes, sizes, readers, and failure behavior are different:

ValueAppropriate home in the Incident App
API base URLExposed Flow configuration
Retry limitExposed Flow configuration
Preferred languageExposed Flow configuration
User’s OpenAI API keySecret runtime configuration for local BYOK
Last successful synchronizationCache if it is only a hint; database if it is a durable fact
Reference dataFile, cache, or database according to size, freshness, and query needs

The syntax is the easy part. The design question is: who owns this value, and how long must it remain true?

State should live at the narrowest scope that satisfies its real lifetime. A Flow variable is not a tiny database, a cache is not a system of record, and source code is never a credential store.

Release check: Each run currently receives fresh mutable Flow variables. Top-level const means non-exposed and top-level let means exposed; neither means immutable. @runtime and @secret can be combined. Web and Desktop store configured values in local IndexedDB, keyed by App and variable, so the defensible current wording is device-profile local, not explicitly user-keyed. Interactive execution paths prompt for missing values before running, but direct or unattended paths do not uniformly reject them: the runtime can still fall back to a Board/Event default or null. @readonly currently disables editing surfaces but does not block Set Variable during execution. Per-user/device identity, universal configuration preflight, and runtime enforcement of @readonly are intended contracts that are not complete today.

Inside a function or Event body, a binding usually names a value produced by the graph:

const normalized = report.trim()
const urgent = normalized.contains({
substring: "production is on hold",
ignoreCase: true,
})

normalized is not an independent storage slot. It names the output of String Trim. urgent names the Boolean output of String Contains. In the workflow, consumers connect directly to those producer pins.

A mutable local can model an accumulator:

let attempts = 0
attempts = attempts + 1

The reconciler can materialize a local variable and its Get/Set nodes when execution paths need a shared mutable value. Its lifetime is still the current run. Starting the Event again creates new in-memory state from the configured defaults.

Top-level declarations describe Board variables:

const urgentPhrase = "production is on hold"
let retryLimit = 3

Nodes throughout the Flow can read or write them through generated Get and Set operations. Every run receives its own variable map, so two concurrent invocations do not deliberately communicate through retryLimit. A write in one run does not update the declaration for the next run.

This makes Flow variables suitable for:

  • values shared by several nodes during one invocation;
  • counters and flags used by control flow;
  • arrays or Structs accumulated during a run; and
  • configured defaults that the Flow may temporarily transform.

They are not suitable for a last-sync record, account balance, inventory count, durable job state, or anything expected to survive a new invocation. Put those facts in App Storage or a database.

11.2 Top-level const and let describe exposure

Section titled “11.2 Top-level const and let describe exposure”

TypeScript readers need to unlearn one association at document scope:

DeclarationCurrent FlowScript meaning
const value = …Create a non-exposed Board variable
let value = …Create an exposed Board variable

Both runtime values are mutable today. The difference is configuration visibility.

An exposed and editable let appears in the App’s Configuration screen. A person authorized to change the App or Board can adjust the value without opening FlowScript or rearranging a workflow. Events can also carry permitted overrides for exposed variables. The exact people who may do that are decided by the App’s permission policy, not by let itself.

For the Incident App, these are reasonable exposed defaults:

@description("Base URL used by the incident AI provider")
@category("Incident AI")
let apiBaseUrl = "https://api.openai.com/v1"
@description("Maximum number of provider attempts")
@category("Incident AI")
let retryLimit = 3
@description("Language used for generated incident explanations")
@category("Incident AI")
let preferredLanguage = "en"

The Flow author decides which values are configuration at all. App configurators decide their shared defaults later. A caller should not be allowed to rewrite an arbitrary non-exposed variable merely because it can guess its ID.

Changing an exposed default changes Board configuration; it is not a private preference for just one runner. If language truly belongs to each person or device, make it runtime-configured instead. If production and development need different values for the same unattended Event, configure those Event or deployment boundaries explicitly rather than making the Flow detect its environment through hidden assumptions.

11.3 Decorators carry platform consequences

Section titled “11.3 Decorators carry platform consequences”

Variable decorators are metadata that both authoring views can preserve:

DecoratorMeaning today
@description("…")Explain what the configurator must provide
@category("…")Group related values in configuration interfaces
@readonlyMark the variable non-editable in current authoring/configuration UI
@runtimeTake the configured value from the runtime channel
@secretHide the value from FlowScript and sensitive authoring/read paths
@schema("…")Attach legacy inline Struct schema metadata

Prefer a named interface over @schema when source should describe a Struct contract. Decorators must sit immediately above their declaration. Canonical order is description, category, legacy schema, secret, readonly, then runtime.

@readonly is currently weaker than its name

Section titled “@readonly is currently weaker than its name”

This declaration describes a value that App configuration should not edit:

@description("Provider selected by the Flow author")
@readonly
const providerName = "OpenAI"

Today, @readonly sets the Board variable’s editable flag to false. The variables panel and configuration UI disable edits, but a Set Variable node can still change the in-memory value during a run. That is an implementation gap. The intended meaning should become a real runtime write barrier as well; until then, do not treat @readonly as a security boundary or integrity guarantee.

This is deliberately separate from top-level const. const controls exposure. @readonly expresses whether mutation is allowed. Those concepts deserve separate syntax because a hidden value can still be mutable, while an exposed reference value may eventually need to be visible but immutable.

11.4 Runtime configuration is runner-specific input

Section titled “11.4 Runtime configuration is runner-specific input”

Use @runtime when the Flow definition should contain the contract but not one shared value:

@description("Path to the local incident export directory")
@runtime
const incidentExportPath: Path

The Flow retains the name, type, description, and decorators. The standard Web and Desktop clients store the configured JSON value outside the Flow in local IndexedDB. A saved value overrides the Board default for that run. A non-secret runtime value may be included in a remote invocation payload.

The intended scope is per user and device. The current store does not yet encode both dimensions: its record key is the App ID plus variable ID, inside that browser/Desktop profile. This gives device-profile isolation, but it does not independently distinguish two Flow-Like accounts using the same local profile. The implementation and documentation should be aligned before the book promises a strict user-and-device key.

Interactive execution currently follows a useful preflight:

Run requested
├─ saved records present ─▶ execute
└─ values missing ─▶ configuration dialog ─▶ save ─▶ execute

The founder’s rule is stronger: a required runtime value must be configured before any node runs. That should hold for interactive, API, scheduled, internal, and agent-initiated execution. Currently the interactive execution service performs the check, but that check proves only that a record was saved—not that its value is still valid for the consuming node. The core runtime does not enforce universal presence. When no override is supplied, it resolves Event configuration or the Board default and can ultimately use null. That makes universal preflight a required runtime follow-up rather than a current guarantee.

11.5 Secrets never become an authoring channel

Section titled “11.5 Secrets never become an authoring channel”

A BYOK credential can combine @secret and @runtime:

@description("Bring-your-own OpenAI API key for local execution")
@category("Incident AI")
@secret
@runtime
const openAiApiKey: string

The declaration is intentionally value-free. FlowScript rejects a non-empty authored secret initializer. Rendering an existing secret omits its stored value, so opening the source, copying it, sending it to FlowPilot, or applying an unrelated edit does not become a secret-read channel.

For a local interactive run, Desktop can prompt once and save the value in the local runtime-value store. The field is masked and can be revealed deliberately. The store is device-local but is not, by itself, a claim of independent encryption at rest; the operating-system account and application data still need protection.

The standard clients filter locally stored secrets out of remote execution payloads. This prevents a browser or Desktop client from casually forwarding a device credential to the hosted executor, but it also means this declaration does not magically make remote BYOK work. An unattended remote Event needs its credential configured through trusted server-side Event configuration. Current Event reads return blank secret values and preserve the stored value when an editor saves an unrelated change; the value is never returned as ordinary configuration. The exact at-rest protection still depends on the deployment and must not be inferred from masking alone.

The OpenAI provider call can then consume the value like any other typed input:

const model = ai::provider::openai({
provider: "OpenAI",
endpoint: apiBaseUrl,
apiKey: openAiApiKey,
})

This is the right kind of BYOK example because it shows the complete boundary: the Flow author declares a required credential, the runner supplies it through a trusted configuration surface, and the provider node receives it without embedding it in source. It does not imply that every remote execution mode can use the locally stored key.

For hosted OpenAI BYOK, the stronger current path is a private provider/model profile. Its provider credential is stored separately from public model metadata, encrypted by the server, omitted from ordinary profile responses, and hydrated only inside the trusted execution path. This also avoids copying the same API key into every Flow. Offline Desktop execution has a different boundary: it must download the credential into local application settings, so protection of the device profile still matters.

Secret metadata reduces exposure; it cannot make an author-written log safe. There is no universal redaction pass that recognizes every credential copied into an arbitrary message. Never log the key, put it in a ticket, interpolate it into an error, or return it to the caller.

The text editor cannot validate a value it is forbidden to see. If an existing secret has a hidden default, FlowScript will not change its type, container shape, or schema while preserving that unknown value. Declassifying it also cannot replace it with model-authored text in one step. The safe sequence is to clear/declassify through the guarded path, then make the ordinary type or value change explicitly.

These restrictions can feel slower than editing a string. That friction is the point: source and AI tools may modify the credential’s contract, but they do not gain the authority to read or write the credential itself.

Choose by the cost of losing the value, not by the convenience of its write node. Use this decision table before creating another Flow variable:

MechanismLifetime and ownershipUse it forDo not use it for
Local bindingOne body path in one runDerived values and readable namesCross-run state
Flow variableShared mutable state inside one runCounters, flags, accumulatorsDurable records
Exposed variableShared App/Board configuration defaultURLs, limits, feature choicesPersonal secrets
Runtime valueLocal client profile/device configurationLocal paths, personal preferences, local BYOKShared unattended remote credentials
Event overrideOne configured Event boundaryEnvironment or trigger configurationArbitrary caller mutation
CacheCross-run but disposable, optionally expiringSmall hot values and recomputable resultsAudit facts or irreplaceable data
App fileDurable object/blob storageDocuments and large reference datasetsHighly concurrent field updates
Database/Data StudioDurable, queryable recordsSync state, entities, history, concurrent workTemporary expression results
Chat/session stateOne interaction contextConversation continuityApp-wide truth

Cache deserves special discipline. The current key-value cache supports App and user scopes, namespaces, expiration, deletion, and values of roughly one mebibyte or less. It is distinct from the evictable file cache directory. If losing lastSuccessfulSync merely causes a safe repeat sync, the key-value cache is reasonable. If losing it could skip work, repeat an irreversible side effect, compromise recovery, or break an audit obligation, store it as a durable database record with the retention policy that obligation requires.

Reference data depends on how it behaves:

  • use an App file for a large, versioned, mostly read-only dataset;
  • cache a small parsed or frequently accessed derivative that can be recreated; and
  • use a database when records are updated, filtered, joined, governed, or changed concurrently.

One application may use all three: a source file, normalized database records, and a cache of the hottest lookup result. “One platform” does not mean “one storage primitive.” It means these mechanisms share the same App, permissions, runtime, and evidence model.

Flow-Like’s current native data path can retain underlying storage versions, but versioning is not a promise of permanent history. Optimization can prune old versions, and teams should set cleanup and retention policy deliberately. If regulation or recovery requires an immutable journal, model that requirement explicitly instead of treating database maintenance history as an audit log.

Chat state is narrower than its friendly names can suggest. A local chat session belongs to one conversation; current “global” chat session state spans chats for the same App/Event on that local client. It is useful for conversational continuity, not organization-wide, cross-device business truth or security policy.

11.7 Read the complete configuration example

Section titled “11.7 Read the complete configuration example”

The canonical Chapter 11 fixture keeps configuration and a BYOK key visible without putting any credential value in the repository:

@description("Base URL used by the incident AI provider")
@category("Incident AI")
let apiBaseUrl = "https://api.openai.com/v1"
@description("Maximum number of provider attempts")
@category("Incident AI")
let retryLimit = 3
@description("Bring-your-own OpenAI API key for local execution")
@category("Incident AI")
@secret
@runtime
const openAiApiKey: string

On the Board, these become typed variables with generated Get/Set operations. The exposed values appear in App configuration. The runtime secret appears in the device-local configuration surface and is absent from rendered source. The provider builder consumes those values through ordinary typed pins.

The fixture deliberately does not declare lastSuccessfulSync or reference data as global variables. Their absence is part of the design. Good state modeling is often visible in the values you refuse to put into convenient but short-lived storage.