Lifecycle
The lifecycle service in Flows allows the developer to make their apps and blocks stateful, enabling them to manage internal state over time as well as manage external resources.
For example, an app installation can automatically create a webhook subscription in an external system, pointing at its own HTTP endpoint. Removing the installation can cause the webhook to be deleted. This provides a seamless experience for users, who don’t have to manually set up or tear down external resources when adding or removing apps.
State can also be stored in KV storage for persistence across syncs.
As another example, a block can provision a remote resource (like a storage bucket) when dropped on the canvas, update its configuration when the block config changes, and delete the resource when the block is removed. This provides a convenient declarative way to manage supporting infrastructure and services directly from the canvas.
Apps and blocks can selectively expose their state to other apps and blocks through signals, allowing for powerful inter-app and inter-block dependencies and reactive updates.
Lifecycle handlers
Section titled “Lifecycle handlers”Apps and blocks can implement lifecycle capabilities by defining special handlers - onSync and (optionally) onDrain - that are called by the Flows control plane to manage the entity’s state.
The onSync handler
Section titled “The onSync handler”// For an app.onSync?: (input: AppInput) => Promise<AppLifecycleCallbackOutput>;
// For a block.onSync?: (input: EntityInput) => Promise<EntityLifecycleCallbackOutput>;onSync is responsible for reconciling the block or app state with its desired configuration. If defined, this function is called:
- once an entity is confirmed
- when configuration is changed
- when the installation code changes
- when a user manually triggers a sync
- when triggered from within the app or block code using the
lifecycle.sync()function
The result of the onSync function indicates the new lifecycle status, any signal updates, and optionally when to schedule the next sync. If the reconciliation is successful, it should return a ready status. If there was an error, it can return a failed status. If the entity is still in the process of reconciling, it can return an in_progress status and schedule itself to be called again later. Regardless of the outcome, it can also update signal values that other blocks can refer to.
This approach differs from OpenTofu where the entire state must be reconciled in one go, with the entire state being computed and updated all at once. onSync allows for far more flexibility and granular handling of asynchronous operations and entities combining multiple building blocks which can succeed or fail independently.
The onDrain handler
Section titled “The onDrain handler”// For an app.onDrain?: (input: AppInput) => Promise<AppLifecycleCallbackOutput>;
// For a block.onDrain?: (input: EntityInput) => Promise<EntityLifecycleCallbackOutput>;onDrain is responsible for cleaning up any external resources or state when the entity is being removed. If defined, this function is called when:
- an app installation is being deleted
- a block is being removed from the canvas
- a user manually triggers a drain
- when an entity status is
drainingand thelifecycle.sync()function is triggered its code
The return value of the onDrain indicates the new lifecycle status and optionally when to schedule the next drain attempt. Similar to onSync, this handler can return a draining status and schedule itself to be called again later if cleanup is a long-running operation. If cleanup fails, it can return a draining_failed. If cleanup is successful, it should return a drained status, indicating that all resources have been cleaned up and the entity can be safely deleted.
The sync function
Section titled “The sync function”The lifecycle.sync() function can be called from within any block or app handler to schedule a sync of the current entity. This is useful when an external event indicates that the state may have changed and a reconciliation is needed. It should always be safe to call this function, as it will simply queue a sync that is meant to be idempotent.
Accessing lifecycle data
Section titled “Accessing lifecycle data”Lifecycle data (status and signals) is available to all block and app handlers through their inputs.:
interface AppContext { // available as input.app // ... other fields status: string; signals: Record<string, any>;}
interface EntityContext { // available as input.block // ... other fields status: string; signals: Record<string, any>;}It is thus possible to adapt behavior based on the current lifecycle status or to use signal values computed during the lifecycle operations. Note however that these are read-only views of the lifecycle state. To update signals or status, use the onSync handler.
Declaring signals
Section titled “Declaring signals”While not strictly required, it is recommended to declare the signals that your app or block exposes in its schema. This allows you to describe the purpose of the signal as well as mark certain signals as sensitive, to prevent them from leaking through the GUI. In one of the examples below, we declare a bucketId signal that holds the identifier of a provisioned storage bucket.
Prompts (apps only)
Section titled “Prompts (apps only)”Prompts solve a specific problem: some integrations require user browser context to complete authentication or setup. Machine-to-machine credentials (API keys, service accounts) aren’t sufficient for OAuth flows, GitHub App creation, or other browser-based authorization mechanisms.
A prompt is a clickable element displayed on the app installation page that redirects the user to an external service. After the user completes the external flow (like OAuth consent), your app receives a callback and can proceed with installation.
When to use prompts
Section titled “When to use prompts”Use prompts when you need:
- OAuth flows where the user must authorize your app in their browser
- Service-specific setup like creating a GitHub App with user-signed manifest submission
- Browser-based authentication that can’t be automated with credentials alone
Don’t use prompts for simple manual approvals or configuration validation - those can be handled through config fields and sync logic.
Examples
Section titled “Examples”Below are some complete examples of apps and blocks implementing lifecycle capabilities documented above.
Simple resource management
Section titled “Simple resource management”Here’s a complete example of a block that provisions an object storage bucket using a simple hypothetical cloud provider API.
import { AppBlock } from "@slflows/sdk/v1";
export const storageBucketBlock: AppBlock = { name: "Object Storage Bucket", description: "Spacelift Cloud object storage bucket",
config: { name: { name: "Bucket name", type: "string" required: true, fixed: true, // once set, the name cannot be changed. }, },
signals: { id: { name: "Bucket ID" } },
onSync: async (input) => { if (input.block.signals.bucketId) { return { newStatus: "ready" }; // Bucket already exists, nothing to do }
try { const { id } = await client.createBucket({ input.block.config.name }); return { newStatus: "ready", signalUpdates: { bucketId: id } } } catch (error) { console.error("Error creating bucket:", error); return { newStatus: "failed" }; } },
onDrain: async (input) => { const { bucketId } = input.block.signals; if (!bucketId) { return { newStatus: "drained" }; // No bucket to delete }
try { await client.deleteBucket({ id: bucketId }); return { newStatus: "drained" }; } catch (error) { console.error("Error deleting bucket:", error); return { newStatus: "draining_failed" }; } }}Long-running creation
Section titled “Long-running creation”Let’s assume that the bucket from the example above takes time to be fully provisioned. While the ID is available immediately and the bucket can be referenced by other blocks, it may take time before it’s ready to use. We can handle this by returning an in_progress status from onSync and scheduling another sync attempt later. Also note how we use the custom status description to provide feedback to the user.
onSync: async (input) => { if (input.block.signals.bucketId) { // Check if the bucket is ready const status = await client.getBucketStatus({ id: input.block.signals.bucketId }); if (status === "ready") { return { newStatus: "ready" }; } else { return { newStatus: "in_progress", customStatusDescription: "Still creating...", nextScheduleDelay: 30, // check again in 30 seconds }; } }
try { const { id } = await client.createBucket({ input.block.config.name }); return { newStatus: "in_progress", signalUpdates: { bucketId: id }, customStatusDescription: "Creation started", nextScheduleDelay: 30, // check again in 30 seconds }; } catch (error) { console.error("Error creating bucket:", error); return { newStatus: "failed" }; }},Prompt-based OAuth app installation
Section titled “Prompt-based OAuth app installation”The prompt appears on the installation page. When clicked, it opens the OAuth provider in the user’s browser. After authorization, the provider redirects back to your app’s HTTP endpoint with an authorization code. Your handler exchanges the code for tokens, stores them, deletes the prompt, and calls lifecycle.sync() to continue the sync process.
onSync: async (input) => { const tokens = await kv.app.get("oauth_tokens");
if (!tokens?.value) { // Need OAuth - create prompt await lifecycle.prompt.create( "oauth-flow", "Authorize access to your account", { redirect: { url: `https://provider.com/oauth/authorize?client_id=${input.app.config.clientId}&redirect_uri=${input.app.http.url}/callback`, method: "GET" } } );
await timers.app.set(300, { promptKey: "oauth-flow", inputPayload: { operation: "timeout" } }); // See scheduling docs for more on timers
return { newStatus: "in_progress", customStatusDescription: "Waiting for authorization..." }; }
return { newStatus: "ready" };},
http: { onRequest: async ({ request }) => { if (request.path === "/callback") { const { code } = request.query;
// Exchange code for tokens (see KV storage docs for more on kv.app.set) const tokens = await exchangeCodeForTokens(code); await kv.app.set({ key: "oauth_tokens", value: tokens });
// Complete the flow await lifecycle.prompt.delete("oauth-flow"); await lifecycle.sync();
return { statusCode: 302, headers: { Location: input.app.installationUrl } }; } }},
onTimer: async ({ timer }) => { if (timer.prompt) { await lifecycle.prompt.delete(timer.prompt.key); await lifecycle.sync(); }}