From flows-app-dev
Guidance for building Spacelift Flows apps. Whenever you're working on a Flows app (a TypeScript project using @slflows/sdk, with blocks/, main.ts, and package.json), ALWAYS load this skill first for app structure, patterns, and best practices.
How this skill is triggered — by the user, by Claude, or both
Slash command
/flows-app-dev:flows-app-devThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Flows is a workflow automation tool targeted at DevOps engineers. You have blocks on a canvas connected by lines. Those blocks may have multiple inputs and outputs on which they send and receive events. We call these blocks "entities" as they can be very powerful. New entities can be implemented by means of apps which are implemented in JavaScript. Those entities can have http endpoints, manage...
Flows is a workflow automation tool targeted at DevOps engineers. You have blocks on a canvas connected by lines. Those blocks may have multiple inputs and outputs on which they send and receive events. We call these blocks "entities" as they can be very powerful. New entities can be implemented by means of apps which are implemented in JavaScript. Those entities can have http endpoints, manage infrastructure resources, have a lifecycle, and more. Entities live in flows.
All configuration expressions and the code of apps are executed on agents, which are connected to the gateway via websockets. Those agents are responsible for managing Node.js runtimes for apps and for flows.
This repository is an app repo based on our Flows App Template. It can be used as a starting point for building new Flows apps.
When working on the app, always make sure to read the appRuntime.ts from https://docs.useflows.com/appRuntime.ts . This is later injected by the Flows runtime, you should never include it yourself. It's presented there as a reference.
If necessary, you may also consider reading the documentation about building Flows apps at https://docs.useflows.com/developers/building-apps/ , specifically:
If necessary, you can also find other apps in public repos of the https://github.com/spacelift-flows-apps organization.
This app demonstrates the standard patterns for Flows apps:
{{app_name}}/
├── blocks/ # Block implementations
│ ├── index.ts # Block registry and exports
│ └── exampleBlock.ts # Example block implementation
├── .github/workflows/ci.yml # CI/CD pipeline
├── main.ts # App definition
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
└── README.md # Documentation and setup guide
main.ts)The app requires two configuration values:
apiKey (secret) - API authentication keybaseUrl (text) - API endpoint URL with default valueblocks/)The template uses a clean block organization pattern:
blocks/index.ts - Central registry that exports all blocks as a dictionaryblocks/exampleBlock.ts - Example block implementationmain.ts - Imports blocks via Object.values(blocks) for clean registrationExample Block Features:
const exampleBlock: AppBlock = {
name: "Block Name",
description: "What this block does",
category: "Category",
inputs: {
default: {
name: "Input Name",
description: "Input description",
config: {
/* JSON Schema */
},
onEvent: async (input, { events }) => {
// Block logic with error handling
},
},
},
outputs: {
default: {
name: "Output Name",
description: "Output description",
type: {
/* JSON Schema */
},
},
},
};
// Block logic - just throw errors, don't wrap in success/failure objects
const result = await someOperation();
await events.emit(result);
const apiKey = input.app.config.apiKey as string;
const baseUrl = input.app.config.baseUrl as string;
npm installnpm run typechecknpm run formatnpm run bundlev1.0.0 formatThe template includes a complete CI/CD system:
versions.jsonFor commonly-static fields, try providing suggestValues. For example, in the Jira app we have
async function fetchIssueTypes(
jiraUrl: string,
email: string,
apiToken: string,
projectKey: string,
): Promise<IssueType[]> {
const client = createJiraClient({ jiraUrl, email, apiToken });
const allTypes: IssueType[] = [];
let startAt = 0;
const maxResults = 50;
for (let page = 0; page < 10; page++) {
const response = await client.get<IssueTypePagedResponse>(
`/issue/createmeta/${projectKey}/issuetypes?startAt=${startAt}&maxResults=${maxResults}`,
);
if (!response.issueTypes || response.issueTypes.length === 0) break;
allTypes.push(...response.issueTypes);
if (startAt + response.issueTypes.length >= response.total) break;
startAt += response.issueTypes.length;
}
return allTypes;
}
const getIssueTypes = memoizee(fetchIssueTypes, {
maxAge: 60000,
promise: true,
});
// ...
inputs: {
default: {
config: {
projectKey: {...},
issueTypeName: {
name: "Issue Type",
description: "The name of the issue type (e.g., 'Bug', 'Task', 'Story', 'Epic')",
type: "string",
required: true,
suggestValues: async (input) => {
const { jiraUrl, email, apiToken } = input.app.config;
const projectKey = input.staticInputConfig?.projectKey as
| string
| undefined;
if (!projectKey) {
return {
suggestedValues: [],
message:
"Configure static value for Project Key to receive suggestions.",
};
}
const allTypes = await getIssueTypes(
jiraUrl as string,
email as string,
apiToken as string,
projectKey,
);
let values = allTypes.map((type) => ({
label: type.name,
value: type.name,
description: type.description,
}));
if (input.searchPhrase) {
const searchLower = input.searchPhrase.toLowerCase();
values = values.filter(
(v) =>
v.label.toLowerCase().includes(searchLower) ||
(v.description &&
v.description.toLowerCase().includes(searchLower)),
);
}
return { suggestedValues: values.slice(0, 50) };
},
},
The memoization helps avoid making a ton of api calls as the user is fine-tuning their search phrase.
It's not worth it providing those for fields that are very dynamic, and the user will generally want to base on event data.
blocks/ directorysensitive: true for sensitive configurationentrypoint: true.blocks/ directory (e.g., blocks/myBlock.ts)blocks dictionary in blocks/index.tsblocks/index.ts for external usenpm run typecheckExample:
// blocks/myBlock.ts
export const myBlock: AppBlock = {
/* block definition */
};
// blocks/index.ts
import { myBlock } from "./myBlock.ts";
export const blocks = {
example: exampleBlock,
my: myBlock, // Add here
} as const;
main.tsinput.app.config.fieldNamedefault: fields with required: false, when applicable, rather than using an optional field and conditionally providing a default value in app handlers.This template provides a solid foundation for building production-ready Flows apps with modern development practices and automated deployment.
The app can be submitted to Flows as a custom app, or added to a registry.
The user can use flowctl to create a custom app in their Flows organization, and then a version of the app inside of that. They will more or less have to run:
flowctl auth login # Follow the prompts to authenticate
flowctl app create # Follow the prompts to create the app
flowctl version update --entrypoint main.ts --watch # Follow the prompts to create a version in watch mode - this will update the app live as you make changes. Can skip --watch to just do a one-time upload.
More details can be found here:
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub spacelift-flows-apps/agent-skills --plugin flows-app-dev