Resolve connections in headless LFX
A flow stores a connection reference, such as google/work, in place of a credential.
The reference is portable and non-secret: it names a provider and a connection, and says nothing about where the credential lives or who is allowed to use it.
Whatever executes the flow — a Langflow backend, lfx run, lfx serve, or your own embedding host — resolves that reference into a short-lived credential at the moment a component needs it.
This page covers the headless path, where there is no Langflow database. For the Langflow backend's own OAuth broker and connection records, see Configure connection OAuth.
How resolution works
Every host implements the same contract, BaseConnectionResolverService:
| Step | What happens |
|---|---|
A component declares a ConnectionRefInput | The flow stores the handle provider/name, plus the provider id and the scopes the action requires. |
The component calls self.resolve_connection("field") | It receives a lazy CredentialLease. Nothing has been resolved yet. |
| The lease is awaited | The host's resolver runs, subject to the deny floor below, and returns a ResolvedCredential. |
| The credential is used | ResolvedCredential.access_token is a SecretStr, and the object refuses pickling, so a token cannot enter a graph snapshot, a job payload, or a process cache. |
| The token nears expiry | The lease re-resolves under its own lock, so concurrent callers sharing that lease coalesce their refresh. Separate leases can resolve independently. |
Which resolver runs depends on the host:
| Host | Resolver | Credential source |
|---|---|---|
| Langflow backend (including Langflow Enterprise serving mode) | DatabaseConnectionResolverService | Encrypted connection records, refreshed by the worker that uses them. |
lfx run, lfx serve, embedded LFX | EnvConnectionResolver (the default) | LF_CONNECTION__* from the request scope, then the process environment. |
| Your host | Your BaseConnectionResolverService subclass | Whatever you implement, such as a secret manager. |
The rest of this page is about the last two rows.
The portable deny floor
BaseConnectionResolverService.resolve() enforces the deny floor and scope checks for every resolver; subclasses cannot override it.
Implement _get_access_policy() to return ownership metadata without reading credentials, and _resolve() to fetch credentials after authorization succeeds.
A credential owned by the environment or a secret store — owner_kind="env" — is usable only by a headless_operator principal.
lfx run and lfx serve stamp that principal on the graph; an interactive actor, an anonymous public run, or an unknown principal is denied.
That is what keeps a multi-tenant Langflow backend from serving a machine credential to whoever opens a flow.
The credential wire format
A headless credential is delivered under the key LF_CONNECTION__<PROVIDER>__<NAME>.
The provider segment is uppercased, and punctuation is escaped with its ASCII hex value so provider ids stay distinct:
| Handle | Key |
|---|---|
google/work | LF_CONNECTION__GOOGLE__WORK |
slack/team_bot | LF_CONNECTION__SLACK__TEAM_BOT |
test.provider/work | LF_CONNECTION__TEST_2EPROVIDER__WORK |
The value is either a bare access token or a JSON object:
{
"access_token": "ya29.a0-example",
"token_type": "Bearer",
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
"account": {"id": "person@example.com", "display": "Work"}
}
access_token is the only required field, and access_token, token_type, expires_at, scopes, and account are the only permitted ones.
Any other field is rejected, and refresh_token, client_secret, and password are rejected explicitly.
A headless runtime is given a short-lived token and nothing it could use to mint another one; whatever system holds the long-lived grant owns refresh.
Prefer the JSON form when the injector knows the expiry and the granted scopes.
LFX then fails with auth-expired or scope-missing before the outbound call instead of after the provider returns 401 or 403.
A bare token asserts nothing, so scopes_verified is false; it is accepted only when the action requires no scopes.
An action with required_scopes, including the sample below, rejects missing scope metadata with scope-missing.
When known, supply the token's actual expires_at as an ISO-8601 string or Unix timestamp. The JSON builder below accepts a datetime; never copy a fixed expiry from documentation.
Run a connection-backed flow with lfx run
This sample component resolves a connection and reports only non-secret facts about it. Use it as the shape for your own actions: a component never reads a token out of a flow field, never logs one, and never returns one.
"""Sample connection-backed action for headless LFX hosts.
The component declares a portable connection reference, asks the host to resolve
it into a short-lived credential, and reports only non-secret facts about the
result. Copy this shape for your own actions: a component never reads an access
token out of a flow field, never logs one, and never returns one.
Run it with the flow built by :func:`build_graph`:
export LF_CONNECTION__GOOGLE__WORK='{"access_token":"example","scopes":["https://www.googleapis.com/auth/drive.readonly"]}'
uv run lfx run connection_action_component.py "describe my connection"
Serve it and inject the credential per request instead:
uv run lfx serve connection_action_component.py --no-env-fallback
"""
from __future__ import annotations
from lfx.custom.custom_component.component import Component
from lfx.graph import Graph
from lfx.integrations import integration_action
from lfx.io import ConnectionRefInput, MessageTextInput, Output
from lfx.schema.message import Message
PROVIDER = "google"
REQUIRED_SCOPES = ["https://www.googleapis.com/auth/drive.readonly"]
class ConnectionAccountComponent(Component):
"""Resolve a connection and describe the account behind it."""
display_name = "Google: Describe Connection"
description = "Resolve a connection reference and report the account and granted scopes."
icon = "Google"
name = "SampleConnectionAccount"
inputs = [
MessageTextInput(
name="input_value",
display_name="Input",
info="Ignored by this sample; present so the component can sit in a chat flow.",
required=False,
),
ConnectionRefInput(
name="connection",
display_name="Connection",
provider=PROVIDER,
required_scopes=REQUIRED_SCOPES,
info="Portable handle such as google/work. Never a token.",
),
]
outputs = [
Output(display_name="Account", name="account", method="describe_connection"),
]
async def describe_connection(self) -> Message:
"""Resolve the connection and return non-secret details about it.
``resolve_connection`` returns a lazy lease: nothing is resolved until
``get_credential`` (or ``get_token``) is awaited, and the lease refreshes
under a single lock when the cached token nears expiry. Resolution failures
arrive as the sanitized ``lfx.integrations.errors`` types, so the message
that reaches a client or a log never contains a credential.
"""
lease = self.resolve_connection("connection")
async with integration_action(
self,
provider=PROVIDER,
capability="drive.describe",
owner_kind="env",
):
credential = await lease.get_credential()
account = credential.account.id if credential.account is not None else "unknown"
scopes = ", ".join(sorted(credential.granted_scopes)) or "not reported"
# credential.access_token is a SecretStr and ResolvedCredential refuses
# pickling, so neither this text nor a graph snapshot can carry the token.
text = (
f"connection={lease.ref.to_handle()} account={account} "
f"token_type={credential.token_type} scopes_verified={credential.scopes_verified} "
f"granted_scopes={scopes}"
)
self.status = text
return Message(text=text)
def build_graph(connection: str = "google/work") -> Graph:
"""Build the one-action flow used by the docs, ``lfx run``, and ``lfx serve``."""
from lfx.components.input_output import ChatInput, ChatOutput
chat_input = ChatInput()
account = ConnectionAccountComponent().set(
connection=connection,
input_value=chat_input.message_response,
)
chat_output = ChatOutput().set(input_value=account.describe_connection)
return Graph(chat_input, chat_output)
graph = build_graph()
Supply the credential in the environment and run it:
export LF_CONNECTION__GOOGLE__WORK='{"access_token":"ya29.a0-example","scopes":["https://www.googleapis.com/auth/drive.readonly"]}'
uv run lfx run connection_action_component.py "describe my connection"
Resolution reads the exact key, then the x-langflow-global-var-* alias of that key.
Missing connections
With the built-in EnvConnectionResolver, lfx run validates connection references before execution by checking for injected values.
It also rejects malformed handles. A custom resolver is responsible for its own credential lookup; pre-flight does not require environment keys for it.
--check-variables is on by default; with --no-check-variables the flow starts and fails at the moment the component awaits its lease instead.
Connection 'google/work' could not be resolved. Set LF_CONNECTION__GOOGLE__WORK to a token or credential JSON object.
The message names the key and the wire format, never a value. The same text is what a client sees, so it is safe to log and to forward.
Supply connections per request with lfx serve
lfx serve accepts per-request variables in the request body's global_vars.
They are applied to a deep copy of the graph and bound to that request only, so one caller's credential never becomes an ambient default for the next caller on the same warm worker.
#!/usr/bin/env bash
# Supply a connection for one lfx serve request.
#
# global_vars is per-request: it is applied to a deep copy of the graph and bound
# to this request only, so it never becomes an ambient default for the next caller
# on the same warm worker. With the built-in environment resolver, start the
# server with --no-env-fallback to accept credentials only through global_vars.
# Custom resolvers retain their own credential lookup behavior.
set +x # Never trace credential expansion, even when invoked with bash -x.
set -euo pipefail
: "${FLOW_ID:?set FLOW_ID to the id printed by lfx serve}"
: "${LANGFLOW_API_KEY:?set LANGFLOW_API_KEY}"
: "${GOOGLE_ACCESS_TOKEN:?set GOOGLE_ACCESS_TOKEN to a short-lived access token}"
: "${GOOGLE_TOKEN_TTL_SECONDS:?set GOOGLE_TOKEN_TTL_SECONDS to the actual remaining token lifetime}"
# Give each request separate descriptors because reads consume their contents.
# These here-strings use shell builtins; clear exported secrets before starting
# jq or curl so neither their arguments nor their environments carry credentials.
exec 3<<<"x-api-key: ${LANGFLOW_API_KEY}"
exec 4<<<"${GOOGLE_ACCESS_TOKEN}"
exec 5<<<"x-api-key: ${LANGFLOW_API_KEY}"
exec 6<<<"${GOOGLE_ACCESS_TOKEN}"
unset LANGFLOW_API_KEY GOOGLE_ACCESS_TOKEN
# The sample action requires Drive read-only scope. Assert only scopes actually
# granted by the issuer; a bare token fails this action with scope-missing.
jq -n --rawfile token /dev/fd/4 '{
input_value: "describe my connection",
global_vars: {
"LF_CONNECTION__GOOGLE__WORK": ({
access_token: ($token | rtrimstr("\n")),
scopes: ["https://www.googleapis.com/auth/drive.readonly"]
} | tostring)
}
}' 3<&- 5<&- 6<&- |
curl -sS -X POST "http://localhost:8000/flows/${FLOW_ID}/run" \
-H "Content-Type: application/json" \
-H @/dev/fd/3 --data-binary @- 4<&- 5<&- 6<&-
exec 3<&- 4<&-
# The TRM blob channel: expiry and scopes are checked before the provider call,
# so the run fails with auth-expired or scope-missing instead of a provider 401/403.
#
# expires_at is the expiry the injecting system holds for this token, so it is
# computed at send time. A literal timestamp copied from a document is in the past
# by the time anyone runs it, and every request then fails with auth-expired.
jq -n --rawfile token /dev/fd/6 \
--argjson ttl "${GOOGLE_TOKEN_TTL_SECONDS}" '{
input_value: "describe my connection",
global_vars: {
"LANGFLOW_REQUEST_VARIABLES": ({"LF_CONNECTION__GOOGLE__WORK": ({
access_token: ($token | rtrimstr("\n")),
token_type: "Bearer",
expires_at: (now + $ttl | todate),
scopes: ["https://www.googleapis.com/auth/drive.readonly"],
account: {id: "person@example.com"}
} | tostring)} | tostring)
}
}' 5<&- |
curl -sS -X POST "http://localhost:8000/flows/${FLOW_ID}/run" \
-H "Content-Type: application/json" \
-H @/dev/fd/5 --data-binary @- 6<&-
exec 5<&- 6<&-
Resolution order for one request is:
- Request-scoped
global_vars, exact key. - Request-scoped
global_vars,x-langflow-global-var-*alias. - Process environment, exact key.
- Process environment, alias.
Request scope always wins, so a credential left in the worker's environment cannot override the one the caller sent.
Start the server with --no-env-fallback to remove steps 3 and 4 entirely, which is the right setting for a shared server:
uv run lfx serve connection_action_component.py --no-env-fallback
--no-env-fallback is a lfx serve flag (its environment twin is LFX_SERVE_NO_ENV_FALLBACK); lfx run has no equivalent.
lfx serve --reset-environ (LFX_SERVE_RESET_ENVIRON) additionally restores os.environ after each run, so a flow that mutates the process environment cannot affect the next request.
The v2 workflow router mounted at /api/v2 and durable runs use the same request scope; they name the field globals rather than global_vars.
lfx serve has no connection pre-flight. A missing connection surfaces when the component awaits its lease, as HTTP 500 whose result includes the sanitized Connection ... could not be resolved message. With --no-env-fallback, the remedy asks for request-scoped variables or the host's secret provider instead of an environment variable. The run route does not return a machine-readable integration error code today; embedding hosts can resolve the connection directly and catch typed errors before calling the route.
watsonx Orchestrate and other tool runtimes
The watsonx Orchestrate tool runtime (TRM) calls POST /flows/{id}/run with global_vars, and it can also send a LANGFLOW_REQUEST_VARIABLES key whose value is a JSON-encoded string holding an object of merged variables. A nested object sent in place of that string fails request validation. A string that decodes to a non-object is logged and ignored.
Both channels feed the same request scope, and explicit keys beat keys carried inside the JSON blob.
To satisfy a connection reference from watsonx Orchestrate, the connection variable must be named exactly LF_CONNECTION__<PROVIDER>__<NAME> — the same key ConnectionRef.env_key() derives — either as a global_vars key or as a key inside the object encoded in LANGFLOW_REQUEST_VARIABLES.
See Deploy flows on watsonx Orchestrate for the deploy-time half, where those variables are bound to the deployed tool.
A process-environment LANGFLOW_REQUEST_VARIABLES blob is honored at resolution time but is not unpacked by lfx run's --check-variables pre-flight. Use exact or aliased environment keys for the CLI. An embedding host can supply normalized graph.context['request_variables'] or an active request scope, both of which pre-flight reads; serve/TRM flattens its blob into that scope.
Implement a resolver without Langflow's database
Two samples follow. The first uses the built-in environment resolver, which is what a host gets when it configures nothing.
"""Environment-backed connection resolution for a host without Langflow's database.
``EnvConnectionResolver`` ships with LFX and is the resolver every headless
runtime gets when no other one is configured, so an environment-backed host
implements nothing: it only decides *how* the credential reaches the process.
Two injection channels exist, and the request-scoped one always wins:
1. Process environment. ``LF_CONNECTION__<PROVIDER>__<NAME>`` holds either a bare
access token or a credential JSON object.
2. Request scope. The host binds a per-request map before executing the graph, so
one caller's credential never becomes another caller's ambient default.
``lfx serve`` does this for you from the request body's ``global_vars``.
Both forms are validated by the same wire-format rules: an access token is
required, long-lived secrets (``refresh_token``, ``client_secret``, ``password``)
are rejected outright, and unknown fields are refused.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from lfx.integrations import ConnectionRef, ConnectionResolutionRequest
from lfx.services.authorization.base import ExecutionPrincipal
from lfx.services.connection.env_resolver import EnvConnectionResolver
from lfx.services.variable.request_scope import (
activate_request_variables,
reset_request_variables,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from lfx.integrations import ResolvedCredential
def env_key_for(handle: str) -> str:
"""Return the environment/request-scope key a connection handle resolves from.
Provider punctuation is hex-escaped so ``a.b``, ``a-b`` and ``a_b`` stay distinct:
``test.provider/work`` becomes ``LF_CONNECTION__TEST_2EPROVIDER__WORK``.
"""
return ConnectionRef.parse(handle).env_key()
def credential_json(
access_token: str,
*,
expires_at: datetime | None = None,
scopes: Iterable[str] | None = None,
account_id: str | None = None,
) -> str:
"""Build the JSON wire value for one connection.
Use the JSON form when the injector knows the expiry, the granted scopes, or
the account: LFX then fails with ``auth-expired`` before the call instead of
after the provider rejects it, and with ``scope-missing`` when the action needs
a scope the credential was not granted. A bare token string is the short form
of ``{"access_token": "..."}`` with nothing else asserted, and cannot satisfy
an action that requires scopes. Pass ``scopes=[]`` only when the granted set
is known to be empty; ``None`` leaves it unverified.
"""
payload: dict[str, Any] = {"access_token": access_token}
if expires_at is not None:
payload["expires_at"] = expires_at.astimezone(timezone.utc).isoformat()
if scopes is not None:
payload["scopes"] = sorted(scopes)
if account_id is not None:
payload["account"] = {"id": account_id}
# Deliberately absent: refresh_token, client_secret, password. Refresh is the
# injector's job; a headless runtime only ever receives a short-lived token.
return json.dumps(payload)
def _request(handle: str, required_scopes: Iterable[str]) -> ConnectionResolutionRequest:
# ``headless_operator`` is the only principal kind allowed to use an
# environment-owned connection. lfx run and lfx serve stamp it on the graph
# for you (lfx.run._defaults.apply_run_defaults); a host driving the resolver
# directly builds the same principal here.
return ConnectionResolutionRequest(
ref=ConnectionRef.parse(handle),
principal=ExecutionPrincipal(kind="headless_operator"),
required_scopes=frozenset(required_scopes),
)
async def resolve_from_process_environment(
handle: str,
*,
required_scopes: Iterable[str] = (),
) -> ResolvedCredential:
"""Resolve one connection from ``os.environ``.
Raises ``ConnectionUnresolvedError`` when the key is absent, ``AuthExpiredError``
when the JSON form declares a past ``expires_at``, and ``ScopeMissingError`` when
scope metadata is absent or does not cover *required_scopes*.
"""
return await EnvConnectionResolver().resolve(_request(handle, required_scopes))
async def resolve_with_request_scope(
handle: str,
credentials: Mapping[str, str],
*,
required_scopes: Iterable[str] = (),
) -> ResolvedCredential:
"""Resolve one connection from a per-request map instead of the process environment.
*credentials* is keyed by :func:`env_key_for`. Binding it for the duration of
one request is what keeps a served worker from running a caller's flow on a
credential that some earlier request left in the environment.
"""
token = activate_request_variables(dict(credentials))
try:
return await EnvConnectionResolver().resolve(_request(handle, required_scopes))
finally:
reset_request_variables(token)
The second replaces it with a secret store.
Subclass BaseConnectionResolverService, implement _get_access_policy() and _resolve(), reuse the wire format, and call set_ready() once your client is usable.
The base class authorizes the policy before _resolve() can fetch a secret and verifies required scopes after it returns, including provider-specific scope aliases.
"""A secret-manager connection resolver for a host without Langflow's database.
``SecretManagerConnectionResolver`` is the shape to copy when credentials live in
AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, or any other store: the
resolver owns the lookup, and everything else — the deny floor, the wire format,
expiry, and scope verification — is reused from LFX so a host cannot accidentally
relax an invariant.
``MountedSecretsConnectionResolver`` is a working, dependency-free implementation
that reads a directory of secret files, the shape Kubernetes projected secrets and
Docker secrets already produce. Register it through ``lfx.toml``:
[services]
connection_resolver_service = "secret_manager_resolver:MountedSecretsConnectionResolver"
Registration fails closed. A class that does not subclass
``BaseConnectionResolverService``, or a module that cannot be imported, raises at
service resolution rather than silently falling back to the environment resolver.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
from lfx.integrations import ConnectionUnresolvedError, ProviderUnavailableError
from lfx.integrations.errors import AuthExpiredError
from lfx.services.connection.base import BaseConnectionResolverService, ConnectionAccessPolicy
# _parse_wire_value is internal to LFX today. Reusing it keeps this sample on the
# one wire format LFX validates — bare token or a JSON object whose only allowed
# fields are access_token, token_type, expires_at, scopes and account, and which
# refuses refresh_token, client_secret and password.
from lfx.services.connection.env_resolver import _parse_wire_value
if TYPE_CHECKING:
from lfx.integrations import ConnectionRef, ConnectionResolutionRequest, ResolvedCredential
SecretFetcher = Callable[[str], str | None]
DEFAULT_SECRETS_DIR = "/run/secrets/langflow-connections"
class SecretManagerConnectionResolver(BaseConnectionResolverService):
"""Resolve connection handles from an external secret store.
Subclass and override :meth:`fetch_secret`, or pass a callable to the
constructor. The lookup runs on a worker thread, so a blocking vendor SDK
client does not stall the event loop.
"""
#: Secret name built from the connection handle. Handles are validated by
#: ``ConnectionRef`` (``provider`` is ``[a-z0-9][a-z0-9._-]*``, ``name`` is
#: ``[a-z0-9]+(?:_[a-z0-9]+)*``), so no path separator can be smuggled through.
secret_name_template = "langflow/connections/{provider}/{name}" # noqa: S105 - a store key, not a secret
def __init__(self, fetch_secret: SecretFetcher | None = None) -> None:
super().__init__()
self._fetch_secret = fetch_secret
# A resolver that is not ready fails closed in get_connection_resolver()
# instead of degrading to the environment resolver, so preload whatever
# your store needs (client, auth, cache) before calling set_ready().
if callable(fetch_secret):
self.set_ready()
def secret_name(self, ref: ConnectionRef) -> str:
"""Return the store key holding this connection's credential."""
return self.secret_name_template.format(provider=ref.provider, name=ref.name)
def fetch_secret(self, secret_name: str) -> str | None:
"""Return the raw secret value, or ``None`` when the store has no such secret."""
if self._fetch_secret is None:
msg = "Override fetch_secret() in a subclass or pass a fetch_secret callable"
raise NotImplementedError(msg)
return self._fetch_secret(secret_name)
async def _get_access_policy(self, request: ConnectionResolutionRequest) -> ConnectionAccessPolicy:
"""Describe ownership without reading a secret; the base class authorizes it."""
_ = request
return ConnectionAccessPolicy(owner_kind="env", allow_non_interactive=True)
async def _resolve(
self, request: ConnectionResolutionRequest, policy: ConnectionAccessPolicy
) -> ResolvedCredential:
"""Read the credential only after the base class authorizes the request."""
_ = policy
secret_name = self.secret_name(request.ref)
def fetch_safely() -> tuple[str | None, bool]:
# Catch vendor failures inside the worker so their exceptions never
# cross the async boundary, including through __context__ on Python 3.10.
try:
return self.fetch_secret(secret_name), False
except Exception: # noqa: BLE001 - vendor exceptions can contain credentials
return None, True
raw, failed = await asyncio.to_thread(fetch_safely)
if failed:
raise ProviderUnavailableError(provider=request.ref.provider)
if not raw:
# No secret name and no store detail in the error: the message reaches
# clients and telemetry. env_key is None because this host does not
# resolve from the environment.
raise ConnectionUnresolvedError(request.ref.to_handle(), provider=request.ref.provider)
credential = _parse_wire_value(raw, request)
if credential.expires_at is not None and credential.expires_at <= datetime.now(timezone.utc):
raise AuthExpiredError(provider=request.ref.provider)
# The base class checks verified scope metadata and provider-specific
# scope aliases after this hook returns.
return credential
class MountedSecretsConnectionResolver(SecretManagerConnectionResolver):
"""Read credentials from a mounted secrets directory.
One file per connection, named ``<provider>__<name>``, holding either a bare
access token or the credential JSON object.
"""
def __init__(self, secrets_dir: str | None = None) -> None:
super().__init__()
self._secrets_dir = Path(secrets_dir or DEFAULT_SECRETS_DIR)
self.set_ready()
def secret_name(self, ref: ConnectionRef) -> str:
"""Return the file name holding this connection's credential."""
return f"{ref.provider}__{ref.name}"
def fetch_secret(self, secret_name: str) -> str | None:
"""Read one secret file, refusing any path that escapes the secrets directory."""
root = self._secrets_dir.resolve()
candidate = (root / secret_name).resolve()
if not candidate.is_relative_to(root):
msg = "Resolved secret path escapes the configured secrets directory"
raise ValueError(msg)
if not candidate.is_file():
return None
# Mounted secrets routinely carry a trailing newline from the writer.
return candidate.read_text(encoding="utf-8").strip()
# Adapters for hosted secret stores follow the same shape. They are shown here
# rather than implemented so this sample keeps LFX's dependency set unchanged.
#
# AWS Secrets Manager:
#
# import boto3
#
# class AwsSecretsManagerResolver(SecretManagerConnectionResolver):
# def __init__(self) -> None:
# super().__init__()
# self._client = boto3.client("secretsmanager")
# self.set_ready()
#
# def fetch_secret(self, secret_name: str) -> str | None:
# try:
# return self._client.get_secret_value(SecretId=secret_name)["SecretString"]
# except self._client.exceptions.ResourceNotFoundException:
# return None
#
# HashiCorp Vault (KV v2):
#
# import hvac
#
# class VaultResolver(SecretManagerConnectionResolver):
# def __init__(self) -> None:
# super().__init__()
# self._client = hvac.Client()
# self.set_ready()
#
# def fetch_secret(self, secret_name: str) -> str | None:
# read = self._client.secrets.kv.v2.read_secret_version(path=secret_name)
# return read["data"]["data"].get("access_token")
#
# In both cases the store holds a short-lived access token that some other system
# refreshes. Do not put a refresh token or a client secret in it: _parse_wire_value
# rejects those fields, and a headless runtime has no consent context to use them.
Save secret_manager_resolver.py in a directory on the serving process's PYTHONPATH, or install it as part of your host application and update the module path below.
The sample module is reference code in this repository and is not included in the lfx wheel; copying lfx.toml alone does not install it.
Copy the following configuration to $LANGFLOW_CONFIG_DIR/lfx.toml to select the resolver at deploy time.
Configuration beats an lfx.services entry point, and lfx.toml beats pyproject.toml's [tool.lfx.services]:
# Copy to $LANGFLOW_CONFIG_DIR/lfx.toml to select a host connection resolver.
# Copy secret_manager_resolver.py to a directory on the serving process's
# PYTHONPATH, or install it in your host application's Python environment and
# update the module path below. The sample is not included in the lfx wheel;
# copying this configuration alone does not install the resolver module.
#
# The value is "<module>:<Class>". The module must be importable by the serving
# process, and the class must subclass BaseConnectionResolverService; anything
# else raises at service resolution instead of falling back to the environment
# resolver. The equivalent pyproject.toml section is [tool.lfx.services], and
# lfx.toml wins when both files exist.
#
# Omit the key entirely to keep the built-in EnvConnectionResolver, which reads
# LF_CONNECTION__<PROVIDER>__<NAME> from the request scope and the environment.
[services]
connection_resolver_service = "secret_manager_resolver:MountedSecretsConnectionResolver"
Selection fails closed. A module that cannot be imported, a class that does not subclass BaseConnectionResolverService, or a resolver that never became ready raises at service resolution rather than silently falling back to the environment resolver — a misconfigured plugin can never quietly change which credentials a flow runs on.
A resolver is callable from a process that serves no HTTP: a sidecar or worker resolves connections through the registered BaseConnectionResolverService, not through Langflow API routes.
Error codes
Resolution failures are sanitized IntegrationError subclasses. Their string form is safe for clients, logs, and telemetry: it names handles, keys, and scopes, never a credential.
| Code | Raised when | Host action |
|---|---|---|
connection-unresolved | No credential was supplied for the handle. | Provision the key or the secret, then retry. |
connection-not-authorized | The execution principal may not use this connection. | Run the flow under a headless operator principal, or use an owned connection. |
auth-expired | The credential declares a past expires_at, or the provider rejected it. | Refresh the credential in the injecting system. |
scope-missing | Required scopes are unverified or the declared scopes do not cover them. | Supply verified scope metadata or re-grant with the missing scopes. |
rate-limited | The provider returned 429. | Retry after the provider's backoff. |
provider-unavailable | The provider failed or was temporarily unavailable. | Retry later. |
network-error | The server could not reach the provider because of a timeout, DNS, connection, or TLS failure. | Check the server's outbound network, DNS, proxy, and TLS settings. Check whether a write completed before retrying to avoid duplicate actions. |
action-unsupported | The provider does not support the action. | Use a different action. |
invalid-request | The action's inputs are invalid or were rejected by the provider. | Correct the inputs before retrying. |
resource-not-found | The requested provider resource was not found or is inaccessible. | Check the resource ID and the connected account's access before retrying. |
policy-blocked | The deployment's integration policy blocks the provider or action. | Ask an administrator to approve the integration or action. |
incompatible-tool | The MCP server's tools no longer match the contract pinned by the action. | Upgrade to a bundle release whose pinned tools match the server before retrying. |
Declare connections in deployment artifacts
A project deployment artifact (.lfpkg) lists what its flows need before anything runs.
Alongside required_variables, the manifest carries required_connections: the non-secret handles and the union of the scopes each one's actions require, per flow and aggregated for the project.
{
"schema_version": 4,
"project": {"id": "…", "name": "Marketing"},
"required_variables": ["OPENAI_API_KEY"],
"required_connections": [
{"provider": "google", "name": "work", "scopes": ["https://www.googleapis.com/auth/drive.readonly"]}
],
"flows": [
{
"id": "…",
"name": "Weekly digest",
"path": "flows/….json",
"sha256": "…",
"size": 4096,
"required_variables": ["OPENAI_API_KEY"],
"required_connections": [
{"provider": "google", "name": "work", "scopes": ["https://www.googleapis.com/auth/drive.readonly"]}
]
}
]
}
schema_version is 5 when any flow selects a model provider, 4 when any flow references a connection, 3 when the artifact carries dependencies, and 1 otherwise.
The version rises so an older reader refuses the artifact instead of deploying it without provisioning what the flows need.
Each level is claimed only when its field is populated, so a project that needs no connection or provider still packages as a version an older reader already accepts.
This schema_version belongs to the project artifact manifest and is unrelated to the bundle capability manifest's schema_version described in Extension manifests.
When any flow selects a model provider, the manifest also carries required_providers: the stable provider identities the project's model fields and standalone model components select, per flow and aggregated for the project.
Alongside it, required_models lists the individual models those fields select as {"provider": "openai", "name": "gpt-4o", "model_type": "llm"}, because a deployment policy can approve a provider and still block one of its models. model_type is omitted when the saved selection did not carry one. A component that is itself a model implies a provider without naming a model, so required_models is never a complete mirror of required_providers.
Static provider and model-name overrides on the model selector are reflected in these requirements. Local utilities such as Fake Embeddings do not declare a provider requirement.
Beside them is unresolved_model_fields, the number of model fields whose effective requirements cannot be fully read, including overrides supplied by global variables or graph connections.
A model chosen at run time has no selector to read, so that count is what keeps the lists from reading as the complete set of requirements: a deploy target should treat a non-zero count as "this project may need providers or models these lists do not name".
Check that your deployment tooling explicitly supports the artifact's schema version before using it. Readers limited to lower versions reject it; setting environment variables does not make that artifact compatible.
For headless LFX, provision the listed LF_CONNECTION__* credentials through the request scope or the configured resolver. For Langflow Enterprise serving mode, provision database connections for the deployment owner and enable allow_non_interactive; its database resolver does not use these environment keys. See Executing identity per entry point.
See also
Was this page helpful?