Skip to content

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.

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.

// 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.

// 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 draining and the lifecycle.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 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.

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.

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 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.

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.

Below are some complete examples of apps and blocks implementing lifecycle capabilities documented above.

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" };
}
}
}

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" };
}
},

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();
}
}