Config flows
A config flow is an integration’s setup wizard — the schema-driven, multi-step form a user fills in to add an instance of an integration (an OpenProject URL + API key, an email account, a Signal link QR, …). It is the Home Assistant config-flow analog: each step returns a result that either shows a form, creates a config entry, or aborts. The frontend renders any integration’s form generically from a list of field descriptors, so an integration author writes Python + translations and never touches the SPA.
This page covers the base classes, the field descriptor types, validation and step handling, single- vs multi-step flows, reconfigure, and i18n.
Where the pieces live
| File | Role |
|---|---|
integrations/config_flow.py |
ConfigFlow base class, FlowResult, FlowContext, FlowResultType, the async_show_form / async_create_entry / async_abort helpers |
integrations/simple_flow.py |
SimpleConfigFlow — boilerplate-free single-step base (most integrations use this) |
integrations/manifest.py |
FieldDescriptor, SelectOption, the FieldType literal, the manifest schema (config_flow, single_instance, …) |
integrations/flow_manager.py |
FlowManager — orchestrates start/advance, encrypts secrets, writes the config entry, localizes labels |
integrations/flow_store.py |
Redis-backed flow state (multi-replica safe) |
integrations/translations.py |
Server-side i18n resolution of label/error keys |
integrations/oauth2.py |
OAuth2Session helper for authorization-code flows (build authorize URL, exchange code, refresh tokens) + the canonical redirect URI |
integrations/errors.py |
flow/integration errors mapped to RFC 9457 problem+json (FlowNotFoundError, IntegrationNotFoundError, IntegrationUnavailableError, IntegrationConfigFlowError) |
The above paths are under src/personal_agent/integrations/ in the backend repo
(personal-agent-org/backend). The integration folders themselves (config_flow.py,
manifest.yaml, translations/<lang>.json) live in that same repo under
integrations/<domain>/.
!!! note “Manifest opt-in”
A flow only runs if the integration’s manifest.yaml sets config_flow: true.
An integration with config_flow: false (or unset) is a zero-step flow: enabling
it immediately creates an empty config entry. single_instance: true (the default)
makes start() abort with already_configured if an entry already exists for that
scope.
The ConfigFlow base class
A flow is a subclass of ConfigFlow that declares one or more step methods named
async_step_<step_id>. async_step_user is the mandatory entry point.
class ConfigFlow(ABC):
domain: str = ""
VERSION: int = 1
def __init__(self, context: FlowContext) -> None:
self.context = context
self._collected: dict[str, Any] = {} # plaintext input accumulated across steps
@abstractmethod
async def async_step_user(self, user_input: dict[str, Any] | None) -> FlowResult: ...
# helpers
def async_show_form(self, *, step_id, data_schema, errors=None,
description_placeholders=None) -> FlowResult: ...
def async_create_entry(self, *, title, data, secret_fields=()) -> FlowResult: ...
def async_abort(self, *, reason) -> FlowResult: ...
def async_external_step(self, *, step_id, url) -> FlowResult: ...
async_create_entry’s secret_fields names any derived secrets a step stashed on
self._collected (e.g. OAuth2 tokens obtained on a callback, never typed into a form);
the FlowManager moves them out of the plaintext config into the encrypted secret set.
A step method receives user_input:
Noneon the first entry into a step — returnasync_show_form(...)to render the empty form.- a
dictof submitted values on a subsequent call — validate it, then either re-show the form (with errors), advance to another step, or create the entry.
The flow object is re-instantiated per HTTP step — it holds no state between
steps. Accumulated input from earlier steps is injected by the FlowManager as
self._collected (see State and the flow manager).
FlowContext
self.context carries who/what the flow is for:
| Field | Meaning |
|---|---|
domain |
the integration domain |
scope |
user / group / org / global |
owner_sub |
the owning user’s subject (when user-scoped) |
org_id |
the org id (when org/group-scoped) |
entry_id |
set only when editing an existing entry (reconfigure / options / reauth) |
scope_ref |
the unified principal (user:<sub> / group:<id> / org:<id> / global) stamped on the created entry |
redirect_uri |
the canonical OAuth2 redirect URI ({APP_ORIGIN}/oauth/callback), injected by the FlowManager so an OAuth flow never hardcodes it; empty for non-OAuth flows |
FlowResult
Every step returns a FlowResult whose type is one of:
FlowResultType |
Built by | Effect |
|---|---|---|
FORM |
async_show_form(...) |
render data_schema for step_id, surface errors, description_placeholders |
CREATE_ENTRY |
async_create_entry(...) |
persist a config entry with title + data, end the flow |
ABORT |
async_abort(...) |
end the flow with a reason |
EXTERNAL_STEP |
async_external_step(...) |
hand the user off to an external provider URL (external_url, e.g. an OAuth2 authorize page); the flow is persisted and resumed from the callback route (see OAuth2 / external steps) |
Field descriptor types
A form schema is a list[FieldDescriptor]. The descriptor is the JSON-serializable
description of one field; the generic frontend renderer turns it into the right input.
class FieldDescriptor(BaseModel):
name: str
label: str # i18n KEY (resolved server-side)
type: FieldType = "text"
required: bool = True
default: Any | None = None
options: tuple[SelectOption, ...] | None = None # for select
placeholder: str | None = None # i18n KEY
description: str | None = None # i18n KEY
multiline: bool = False
image: str | None = None # for type="note": a data-URL (e.g. a QR)
filter: dict[str, Any] | None = None # narrow a typed selector
multiple: bool = False # entity/device/area/select → multi-select (value is a list)
label, placeholder and description are i18n keys, not literal text — see
Internationalisation.
Field types (FieldType)
Basics:
type |
Renders / stores | Notes |
|---|---|---|
text |
single-line text | set multiline=True for a textarea |
password |
masked text | envelope-encrypted secret; shown masked (last 4) on reconfigure |
secret |
write-only text | envelope-encrypted secret; never echoed back |
number |
numeric input | preserved as int/float |
bool |
toggle / checkbox | |
select |
dropdown | requires options (a tuple of SelectOption(value, label)) |
note |
read-only display | instructions + optional image (e.g. a device-linking QR) |
Typed selectors (the Home Assistant selector analog) bind a field to a richer
picker the generic renderer knows how to show:
type |
Picks |
|---|---|
entity |
one of the user’s entities — narrow with filter (e.g. {"domain": "openproject", "entity_type": "work_package"}) |
device |
a connected device |
area |
an area/room |
duration |
a length of time (rendered as a duration input, stored as seconds) |
date / time / datetime |
the corresponding picker |
color |
a hex color |
multiple=True turns entity / device / area / select into a multi-select; the
field value is then a list.
!!! note “Secrets are anything password or secret”
FieldDescriptor.is_secret is True for exactly those two types. The
FlowManager records secret field names from every shown form and keeps their
values encrypted in the Redis flow blob and, on completion, in
integration_config_secrets. Non-secret values go into the entry’s plaintext
config.
SimpleConfigFlow — the common case
Most integrations are a single form: show fields, strip/validate the required ones,
optionally run a connection test, create the entry. SimpleConfigFlow encodes that
once. A subclass only sets a SCHEMA (and a title field) and, optionally, overrides a
few hooks:
| Override | Purpose | Default |
|---|---|---|
SCHEMA |
the list[FieldDescriptor] |
[] |
TITLE_FIELD |
which field’s value becomes the entry title | "" (→ domain name) |
ENTRY_FIELDS |
which fields go into the entry data |
all schema fields |
VALIDATE_TIMEOUT |
seconds before validate() is cancelled |
15.0 |
preprocess(user_input) |
normalise raw input | strips text fields; rstrip('/') on url / site / *_url; coerces number |
validate_fields(data) -> dict[str, str] |
sync field-level checks (returns {field: error_key}) |
checks required fields for empty values |
| `async validate(data) -> str | None` | async connection test (returns an error key or None) |
entry_title(data) |
computed title | the TITLE_FIELD value |
entry_data(data) |
the dict stored as entry data | the ENTRY_FIELDS |
SimpleConfigFlow already implements async_step_user and
async_step_reconfigure (both reuse the same handler), so a single-step integration
is reconfigurable for free.
!!! warning “Never leak credentials into form text”
validate() runs inside asyncio.wait_for(VALIDATE_TIMEOUT). A timeout or any
uncaught exception is logged at ERROR and the user sees a generic
connection_failed error — the raw exception (which may carry host/port/credential
fragments) is never shown. Return an explicit error key for clear failures
(e.g. auth_failed on a 401), and prefer not to block on ambiguous network
errors so a valid credential behind a flaky endpoint isn’t rejected.
Worked example — SimpleConfigFlow
The OpenProject flow (integrations/openproject/config_flow.py): a base URL + a
secret API key, validated against the OpenProject API.
from typing import Any, ClassVar
import httpx
from personal_agent.integrations.manifest import FieldDescriptor
from personal_agent.integrations.simple_flow import SimpleConfigFlow
SCHEMA = [
FieldDescriptor(
name="base_url",
label="field.base_url.label",
type="text",
required=True,
placeholder="field.base_url.placeholder",
description="field.base_url.description",
),
FieldDescriptor(
name="api_key",
label="field.api_key.label",
type="secret",
required=True,
description="field.api_key.description",
),
]
class OpenProjectConfigFlow(SimpleConfigFlow):
domain = "openproject"
SCHEMA = SCHEMA
TITLE_FIELD = "base_url"
ENTRY_FIELDS: ClassVar[list[str]] = ["base_url", "api_key"]
def validate_fields(self, data: dict[str, Any]) -> dict[str, str]:
errors = super().validate_fields(data) # required checks
url = str(data.get("base_url") or "")
if "base_url" not in errors and not url.startswith(("http://", "https://")):
errors["base_url"] = "invalid_url"
return errors
async def validate(self, data: dict[str, Any]) -> str | None:
"""Error key on a clear auth failure; None on success OR a network error."""
try:
async with httpx.AsyncClient(
timeout=10.0, auth=httpx.BasicAuth("apikey", data["api_key"])
) as client:
resp = await client.get(
f"{data['base_url']}/api/v3/users/me",
headers={"Accept": "application/json"},
)
if resp.status_code in (401, 403):
return "auth_failed"
return None
except Exception:
return None # don't block a possibly-valid key behind a flaky endpoint
A select field declares its choices as SelectOption(value, label) tuples (the
label is itself an i18n key), as the Helpers integration does:
from personal_agent.integrations.manifest import FieldDescriptor, SelectOption
FieldDescriptor(
name="kind",
label="field.kind.label",
type="select",
default="toggle",
options=(
SelectOption(value="toggle", label="field.kind.toggle"),
SelectOption(value="number", label="field.kind.number"),
SelectOption(value="button", label="field.kind.button"),
),
description="field.kind.description",
)
Multi-step flows
When setup needs more than one screen — a credential step, then a device-linking step,
then a confirmation — subclass ConfigFlow directly and write multiple
async_step_<id> methods. Each async_show_form(step_id=...) names the step the
next submission will be dispatched to, and values from earlier steps are available on
self._collected.
Worked example — multi-step (ConfigFlow)
The Signal flow (integrations/signal/config_flow.py): step 1 collects the number;
step 2 (link) shows a QR note field and polls until the device is linked.
from typing import Any
from personal_agent.integrations.config_flow import ConfigFlow, FlowResult
from personal_agent.integrations.manifest import FieldDescriptor
from .client import linked_numbers, qrcode_link
from .providers import DEFAULT_BASE
USER_SCHEMA = [
FieldDescriptor(
name="number",
label="field.number.label",
type="text",
placeholder="field.number.placeholder",
description="field.number.description",
),
FieldDescriptor(
name="base_url",
label="field.base_url.label",
type="text",
required=False,
default=DEFAULT_BASE,
description="field.base_url.description",
),
]
def _qr_form(self: ConfigFlow, qr: str | None, label: str, error: str | None) -> FlowResult:
fields = [FieldDescriptor(name="qr", label=label, type="note", required=False, image=qr)]
return self.async_show_form(
step_id="link", data_schema=fields, errors={"base": error} if error else None
)
class SignalConfigFlow(ConfigFlow):
domain = "signal"
async def async_step_user(self, user_input: dict[str, Any] | None) -> FlowResult:
if user_input is None:
return self.async_show_form(step_id="user", data_schema=USER_SCHEMA)
number = str(user_input.get("number") or "").strip()
base_url = str(user_input.get("base_url") or DEFAULT_BASE).strip().rstrip("/")
if not number.startswith("+") or not number[1:].replace(" ", "").isdigit():
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors={"number": "invalid_number"}
)
try:
nums = await linked_numbers(base_url)
except Exception:
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors={"base": "unreachable"}
)
if number in nums: # already linked → done
return self.async_create_entry(
title=number, data={"number": number, "base_url": base_url}
)
try:
qr = await qrcode_link(base_url)
except Exception:
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors={"base": "unreachable"}
)
return _qr_form(self, qr, "link.instructions", None) # advance to the "link" step
async def async_step_link(self, user_input: dict[str, Any] | None) -> FlowResult:
# Values from the "user" step are on self._collected.
number = str(self._collected.get("number") or "").strip()
base_url = str(self._collected.get("base_url") or DEFAULT_BASE).strip().rstrip("/")
try:
nums = await linked_numbers(base_url)
except Exception:
nums = []
if number in nums:
return self.async_create_entry(
title=number, data={"number": number, "base_url": base_url}
)
try:
qr = await qrcode_link(base_url) # re-issue a fresh QR
except Exception:
qr = None
return _qr_form(self, qr, "link.not_linked", "not_linked")
Things to note from this example:
- The form for the next step sets
step_id="link", so the next submission is dispatched toasync_step_link. type="note"withimage=<data-url>shows a read-only QR; it carries no value.- Step 2 reads the number/base URL it never saw in its own
user_inputfromself._collected— the manager rehydrated it.
Validation and the flow-result contract
The cardinal rule: validation never raises out as a 500. A step maps bad input to
errors[field] (or errors["base"] for connection-level failures) and re-shows the
form (HTTP 200). The errors dict maps a field name (or "base") to an error key,
which the manager localizes via error.<key> (see below).
description_placeholders lets a step inject runtime values into a step’s localized
description text (e.g. an account name read back from the service).
Single- vs multi-step, and reconfigure
- Single-step: subclass
SimpleConfigFlow, declareSCHEMA. You getasync_step_userandasync_step_reconfigurefor free. - Multi-step: subclass
ConfigFlow, writeasync_step_userplus moreasync_step_<id>methods, chaining viaasync_show_form(step_id=...).
Editing an existing entry runs the same wizard but updates the entry in place rather
than creating a new one. The FlowManager starts an edit when called with an entry_id,
parameterised by a flow_kind that selects the entry step:
flow_kind |
Entry step | Purpose |
|---|---|---|
reconfigure (default) |
async_step_reconfigure |
re-run the setup form to change config |
options |
async_step_options |
edit the entry’s options surface |
reauth |
async_step_reauth |
re-authorize an entry whose tokens/credentials failed (state setup_error) |
Each flow_kind falls back to async_step_user when the integration does not define
that step. The edit then proceeds:
- The existing entry’s non-secret
configand decrypted secrets are pre-loaded intoself._collected, so the first step can reuse or re-show them. async_step_<flow_kind>runs as the first step (orasync_step_useron fallback).- On
CREATE_ENTRY, the manager updates the existing entry instead of inserting a new one (noalready_configuredabort, no duplicate). If the entry vanished mid-flow it aborts withentry_not_found.
SimpleConfigFlow provides async_step_reconfigure by delegating to the user step, so
re-running the same form (pre-filled) is the default behaviour. Add a dedicated
async_step_reconfigure / async_step_options / async_step_reauth only when that edit
path should differ from initial setup.
!!! note “Clearing an optional secret on reconfigure” A secret field omitted from a submission is preserved (the pre-loaded value survives). Submitting it empty explicitly clears it. A non-empty value replaces it.
State and the flow manager
FlowManager (flow_manager.py) orchestrates the whole wizard:
start(...)— instantiates the flow, runs the first step, allocates aflow_id, and persists state. Handles thesingle_instanceabort and the zero-step (no-config) integration case.advance(flow_id, user_input, ...)— rehydrates the flow (non-secretcollected+ decrypted prior secrets →self._collected), dispatches the current step, merges the new input into tracked state, and onCREATE_ENTRYwrites (or updates) the entry and deletes the flow state.abort(flow_id)— drops the flow state.
State lives in Redis (flow_store.py, key personal_agent, ~10 min
TTL), not in process memory, because the API is multi-pod — step 2 may land on a
different replica than step 1. Secret values inside that blob are stored encrypted
(via CryptoService); they are decrypted only transiently to feed the flow or to write
the final entry.
The manager also tracks secret field names seen across forms, so it knows which of
the submitted values to encrypt. A step may compute derived non-secret config (a
value not entered via a form field, e.g. an account number read back from the service)
by stashing it on self._collected; the manager persists those too.
HTTP surface
The flow is driven over HTTP by api/routers/integrations.py:
| Method + path | Action |
|---|---|
POST /config-flows |
start a new flow ({domain, scope, group_id?}) |
POST /config-flows/{flow_id} |
advance with {user_input} |
GET /config-flows/{flow_id}/callback |
resume a flow paused on an EXTERNAL_STEP; the provider’s redirect query params (code/state/…) become the pending step’s user_input |
DELETE /config-flows/{flow_id} |
abort |
POST /config-entries/{entry_id}/reconfigure |
edit an entry via async_step_reconfigure (empty body) |
POST /config-entries/{entry_id}/options |
edit an entry via async_step_options |
POST /config-entries/{entry_id}/reauth |
re-authorize an entry via async_step_reauth |
POST /config-entries/{entry_id}/reload |
clear the entry’s error state and re-sync its entities (not a flow) |
Each flow call returns the serialized FlowResult: type, flow_id, step_id,
title, reason, external_url, localized errors, the localized data_schema, and
description_placeholders. On create_entry the response also carries entry_id. The
serializer resolves every label/placeholder/description/option label and every error
code to a localized string before it reaches the client.
OAuth2 / external steps
A flow that needs the user to authorize at an external provider returns an
EXTERNAL_STEP (via async_external_step(step_id=..., url=...)) instead of a form. The
serialized result carries external_url; the SPA sends the user there (a popup) and,
when the provider redirects back to {APP_ORIGIN}/oauth/callback, forwards the redirect
query params to GET /config-flows/{flow_id}/callback. The manager resume()s the flow
by re-entering async_step_<step_id> with those params as user_input - identical to a
normal advance - so the step finishes the token exchange and returns CREATE_ENTRY.
integrations/oauth2.py provides OAuth2Session for the authorization-code grant: build
the authorize URL, exchange the returned code for tokens, and refresh an expired access
token. The redirect URI is fixed at {APP_ORIGIN}/oauth/callback (oauth_redirect_uri,
also exposed on FlowContext.redirect_uri), so an integration never hardcodes a URL. The
client config (client_id/secret/urls/scopes) comes from the admin
oauth2_application_credentials store, keyed by domain.
!!! note “OAuth tokens are derived secrets”
The tokens are stashed on self._collected under field names declared via
async_create_entry(..., secret_fields=(...)) (or FlowResult.secret_fields); the
FlowManager encrypts them at rest and strips them from the plaintext entry config.
The admin client_secret and the tokens are never logged or serialized into Temporal
inputs (Frozen Contracts #5/#15).
Internationalisation of labels
Field labels and error messages are i18n keys, not literal strings. The integration
ships translations under integrations/<domain>/translations/<lang>.json. The engine
(translations.py) resolves a dotted key (e.g. field.api_key.label) against the
requested language, falling back to en, then to the raw key if nothing matches.
Conventions:
- A field’s
label/placeholder/descriptionare keys likefield.<name>.label,field.<name>.placeholder,field.<name>.description. - A
SelectOption.labelis a key likefield.<name>.<value>. - An error key
Xreturned inerrorsresolves toerror.X. - Free-form
notestep text uses any key you choose (e.g.link.instructions).
Example translations/en.json (OpenProject):
{
"title": "OpenProject",
"field": {
"base_url": {
"label": "OpenProject URL",
"placeholder": "https://openproject.example.com",
"description": "Your OpenProject instance URL (without /api)."
},
"api_key": {
"label": "API key",
"description": "Your personal API key (Account settings → Access tokens → API). Stored encrypted."
}
},
"error": {
"invalid_url": "Enter a URL starting with http:// or https://",
"required": "This field is required",
"auth_failed": "Authentication failed — check the URL and API key."
}
}
Add one <lang>.json per supported language (e.g. en.json, de.json). All hard-coded
keys in Python stay stable; only the JSON values are translated.
Checklist for a new flow
manifest.yaml: setconfig_flow: true(andsingle_instanceas appropriate).config_flow.py: subclassSimpleConfigFlow(single step) orConfigFlow(multi-step); setdomainand the schema; map every failure to an error key.translations/en.json(+ other langs): providefield.*labels anderror.*messages for every key you reference.- Keep secrets as
type="secret"/type="password"so they’re encrypted; put non-secret values into the entryconfig.