A custom component lets you drop your own native SwiftUI view into a FlowPilot flow. You define the component once in the editor (its inputs and the events it can emit), then register a matching native factory in the SDK. When a flow renders a custom component, the SDK builds your view, hands it the resolved inputs, and routes the events your view emits back into the flow’s actions.
This is the supported path for native code inside a flow: a paywall, a date picker, a chart, a camera view, anything SwiftUI can draw.
How it works
The editor and the SDK meet at a contract keyed by component key + version:
- In the editor you define a custom component: a stable Component Key, a version, typed inputs, and named outputs (events). See Custom components in the editor.
- A flow places an instance of that component and binds its inputs (to constants or variables) and its outputs (to actions).
- In your app you call
registerCustomComponent(key:version:definition:) with a factory that returns a SwiftUI view.
- At render time the SDK looks up your factory by key and version, resolves the bound inputs, and calls your factory. Your view emits events through a context object, and the SDK runs the actions the editor wired to those events.
The SDK treats your view as a “dumb renderer”: it receives inputs and emits intent. The flow (defined in the editor) decides what each emitted event does.
Register a component
Register after the SDK is configured and before you present any flow that uses the component.
public func registerCustomComponent(
key: String,
version: Int = 1,
definition: CustomComponentDefinition
)
CustomComponentDefinition carries the input types, the output schemas, and the factory:
public init(
inputs: [String: VariableType]? = nil,
outputs: [String: OutputSchema]? = nil,
factory: @escaping @Sendable @MainActor (CustomComponentProps, CustomComponentContext) -> AnyView
)
inputs: declared input keys and their types (.string, .number, .boolean, .list). Used for validation; the keys must match the input keys you defined in the editor.
outputs: a map of event name to OutputSchema, declaring which events the view can emit and the shape of each event’s payload.
factory: a @MainActor closure that returns your view wrapped in AnyView. It receives the resolved inputs (CustomComponentProps) and an emit context (CustomComponentContext).
OutputSchema declares one event:
public init(description: String? = nil, payload: [String: VariableType]? = nil)
The factory’s first argument, CustomComponentProps, gives typed, read-only access to the resolved inputs. Each accessor has an optional form and a form with a default:
func string(_ key: String) -> String?
func string(_ key: String, default: String) -> String
func number(_ key: String) -> Double?
func number(_ key: String, default: Double) -> Double
func bool(_ key: String) -> Bool?
func bool(_ key: String, default: Bool) -> Bool
func int(_ key: String) -> Int?
func int(_ key: String, default: Int) -> Int
Prefer the default: form so your view always has a value. If a key is missing or has the wrong type, the SDK logs a warning in debug builds and returns your default. The keys you read must match the input keys defined in the editor.
Emit events
The factory’s second argument, CustomComponentContext, is how your view tells the flow that something happened:
func emit(_ eventName: String, payload: [String: Any]? = nil)
func emit(_ eventName: String) // convenience, no payload
The eventName must match an output you declared in the editor (and in the definition’s outputs). When you emit, the SDK runs whatever actions the editor wired to that event in the component’s Interactions. The payload is available to those actions as {{payload.<key>}} (for example to write a value into a variable). See Interactions and actions.
If you emit an event name that is not declared, the SDK still forwards it but logs a warning. If a payload does not match the declared schema, it is still forwarded with a warning. Treat both warnings as a sign your editor definition and SDK code have drifted.
Container info
The context also exposes the space the component was given:
public let containerSize: CGSize
public let containerConstraints: ContainerConstraints
Current-build limitation: containerSize is a fixed placeholder (300 x 200), not the measured layout size (Core renderer has a TODO: Get actual size from GeometryReader). Do not rely on it for precise sizing yet. Size your view from the layout the flow gives the component, or read the geometry yourself with GeometryReader inside your view.
Versioning
Registration is keyed by key + version so a component can evolve without breaking older flows. The version defaults to 1. When a flow references a component, the SDK looks up key at that exact version. For version 1, the SDK also registers the component under the bare key for backward compatibility, so older flows that omit a version still resolve.
The version you register must match CustomComponentRef.version in the flow. If you ship a v2 of a component, register both versions while flows that pin v1 are still live.
Example
A date_picker component with one input (label) and one output (date_selected) carrying a date string and a timestamp number.
import SwiftUI
import FlowPilotSDK
let datePicker = CustomComponentDefinition(
inputs: ["label": .string],
outputs: [
"date_selected": OutputSchema(
description: "Fires when the user picks a date",
payload: ["date": .string, "timestamp": .number]
)
],
factory: { props, context in
let label = props.string("label", default: "Pick a date")
return AnyView(
MyDatePicker(label: label) { date in
let iso = ISO8601DateFormatter().string(from: date)
context.emit("date_selected", payload: [
"date": iso,
"timestamp": date.timeIntervalSince1970
])
}
)
}
)
// Register once, after configure() and before presenting a flow that uses it.
FlowPilot.shared?.registerCustomComponent(
key: "date_picker",
version: 1,
definition: datePicker
)
In the editor, a button or action wired to the date_selected event can read the payload, for example writing {{payload.date}} into a variable with a Set Variable action.
To remove a registration (for example in tests):
FlowPilot.shared?.unregisterCustomComponent(key: "date_picker", version: 1)
The contract
The editor definition and the SDK registration must agree on four things. A mismatch on any of them breaks rendering or events:
| Must match | Editor side | SDK side |
|---|
| Component key | Component Key | key: in registerCustomComponent |
| Version | version | version: in registerCustomComponent |
| Input keys | defined Inputs | keys you read with props.string(...) etc. |
| Event keys | defined Outputs | names you pass to context.emit(...) |
Custom components (this page) are different from custom actions. A custom component emits editor-defined events, which is fully supported. A custom action (an action of kind custom defined in the editor) has no public native registration API on the SDK today. If you need native behavior, model it as a custom component that emits an event, and wire the action to that event. See Error handling for the current state of custom actions.
Common mistakes
- Key or version mismatch. The flow asks for
date_picker v2 but you registered v1 (or a different key). The SDK cannot find your factory and shows a placeholder instead.
- Registering after presenting. Register the component before you call
presentPlacement or createSession. A flow that renders before the factory is registered shows the placeholder.
- Reading an input key the editor did not define.
props.string("titel") (typo) returns your default and logs a warning. Match the editor’s input keys exactly.
- Emitting an event the editor did not define. The event is forwarded but nothing is wired to it, so nothing happens. Declare the output in the editor and in your
outputs.
- Trusting
containerSize. It is a fixed placeholder in the current build (see the warning above). Use GeometryReader if you need the real size.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|
| Component renders as a placeholder box | Not registered, or key/version mismatch | Register the exact key and version the flow uses, before presenting. Watch for the custom_component_not_found error code (customComponentNotFound). |
| View renders but events do nothing | Emitted event name not wired in the editor, or name mismatch | Declare the output in the editor, wire an action to it, and emit the exact name. |
| Inputs are empty or wrong | Input key mismatch or wrong type | Match the editor’s input keys and types; read with the default: accessors and check debug-log warnings. |
| Payload value missing in an action | Payload key not declared or {{payload.key}} typo | Declare the payload field in OutputSchema and reference the exact key in the action. |
Related pages