Build Your First Plugin
You've run workflows and triggered them with webhooks. Now you'll extend the builder itself. Plugins are the power — they let you wire up any API, any service, any internal tool. But learning the plugin structure while also fighting an external API is too much at once.
So we'll build something stupid simple first. No API calls. No credentials. Just a plugin that logs a configurable message. You'll learn the folder structure with zero external complexity. Then in the next lesson, you'll use pnpm create-plugin to scaffold automatically.
Each node on the canvas becomes a step in code. The "use step" directive makes each one independently retryable and observable. What you drag is what runs.
Outcome
You'll create a working "Shout" plugin that takes a message and logs it in ALL CAPS. It'll appear in the action grid, show configurable fields in the UI, execute as a durable step, and show up in the logs.
Fast Track
- Write the step function (the core logic)
- Create the plugin definition (tell the system it exists)
- Run
pnpm discover-plugins(auto-generates wiring)
The Plugin Folder Pattern
Every plugin lives in plugins/[name]/ with this structure:
plugins/shout/
├── index.ts → Plugin definition (registers with the system)
├── icon.tsx → Icon for the action grid
├── credentials.ts → Type definition for credentials
├── test.ts → Connection test (optional)
└── steps/
└── shout.ts → The "use step" function (core logic)
We'll build these in order of importance: step first (the logic), plugin definition second (the registration), wiring third (the executor). Icon and credentials are one-liners.
Check plugins/_template/ — it has all these files ready to copy. The files end in .txt so they don't compile. For learning, we'll build by hand. In Lesson 4, you'll use pnpm create-plugin to scaffold automatically.
Predict the Flow
When you drag a 'Shout' node onto the canvas and click Run, what has to happen for your shoutStep function to actually execute? Think about: How does the workflow executor know which function to call?
Hands-on Exercise
We'll build the Shout plugin in three phases: core logic, registration, and wiring.
Phase 1: The Step Function (Core Logic)
The step function is where the real work happens. Everything else is just wiring to get here.
Create the folder and file:
mkdir -p plugins/shout/stepsWrite the step function (plugins/shout/steps/shout.ts):
import "server-only";
import { type StepInput, withStepLogging } from "@/lib/steps/step-handler";
type ShoutInput = StepInput & {
message: string;
};
type ShoutResult =
| { success: true; shouted: string }
| { success: false; error: string };
async function stepHandler(input: ShoutInput): Promise<ShoutResult> {
if (typeof input.message !== 'string' || !input.message.trim()) {
return { success: false, error: 'Message must be a non-empty string' };
}
const shouted = input.message.toUpperCase();
console.log(shouted);
return { success: true, shouted };
}
export async function shoutStep(input: ShoutInput): Promise<ShoutResult> {
"use step";
return withStepLogging(input, () => stepHandler(input));
}
export const _integrationType = "shout";What this does:
"use step"— marks this function as a durable step (retryable, observable). See Workflows and Steps for how this directive works.withStepLogging— wraps execution with timing and loggingstepHandler— the actual logic (uppercase the message)- Union return type — success OR error, never throw
This is the pattern for every step you'll ever write. The logic is trivial; the structure is what matters.
Phase 2: Plugin Definition (Registration)
Now tell the system this plugin exists.
Create the plugin definition (plugins/shout/index.ts):
import type { IntegrationPlugin } from "../registry";
import { registerIntegration } from "../registry";
import { ShoutIcon } from "./icon";
const shoutPlugin: IntegrationPlugin = {
type: "shout",
label: "Shout",
description: "Log messages in ALL CAPS",
icon: ShoutIcon,
formFields: [], // No credentials needed
actions: [
{
slug: "shout",
label: "Shout Message",
description: "Log a message in uppercase",
category: "Shout",
stepFunction: "shoutStep",
stepImportPath: "shout",
configFields: [
{
key: "message",
label: "Message",
type: "template-input",
placeholder: "Enter message to shout",
required: true,
},
],
},
],
};
registerIntegration(shoutPlugin);
export default shoutPlugin;The key fields:
type+slug→ full action ID is"shout/shout"configFields→ what shows in the properties panelstepFunction→ the exported function name from your step file
Add the icon (plugins/shout/icon.tsx):
import { Megaphone } from "lucide-react";
export function ShoutIcon(props: React.ComponentProps<typeof Megaphone>) {
return <Megaphone {...props} />;
}Add empty credentials (plugins/shout/credentials.ts):
export type ShoutCredentials = {
// No credentials needed for this plugin
};Phase 3: Registration (Auto-Discovery)
Run the plugin discovery script:
pnpm discover-pluginsThis does three things automatically:
- Updates
plugins/index.ts— addsimport "./shout"so your plugin registers on startup - Updates
lib/step-registry.ts— adds the step importer so the executor can find your step - Updates
lib/types/integration.ts— adds "shout" to theIntegrationTypeunion
Never manually edit plugins/index.ts, lib/step-registry.ts, or lib/types/integration.ts. They're auto-generated by discover-plugins. Your changes will be overwritten.
Restart the dev server (new files require restart):
# Ctrl+C to stop, then:
pnpm devTry It
- Open the workflow builder
- Click the + button to add an action
- Find Shout Message in the action grid
- Configure the message field:
hello workflow - Run the workflow
Check your terminal (where pnpm dev is running):
[Workflow Executor] Starting workflow execution
[Workflow Executor] Executing trigger node
[Workflow Executor] Executing action node: Shout Message
[shout] Starting step execution...
HELLO WORKFLOW
[shout] Step completed successfully in 2ms
[Workflow Executor] Workflow execution completed: { success: true, ... }
You should also see:
- The step in the execution logs with timing
- The action in the Code tab's generated workflow
The action ID 'shout/shout' comes from combining which two fields in the plugin definition?
Debugging: "My Plugin Doesn't Show Up"
If Shout isn't in the action grid, check in this order:
1. Did you run discover-plugins?
pnpm discover-pluginsCheck the output — it should say "Found 1 plugin(s): shout"
2. Did you restart the dev server?
pnpm dev3. Check the generated files:
plugins/index.tsshould haveimport "./shout";lib/step-registry.tsshould have a"shout/shout"entry
4. Check your plugin definition matches:
// In plugins/shout/index.ts:
type: "shout", // integration type
actions: [{
slug: "shout", // action slug
stepFunction: "shoutStep", // must match export name
stepImportPath: "shout", // must match filename (without .ts)
}]The action ID is "shout/shout" (type + "/" + slug). The step file must be at plugins/shout/steps/shout.ts and export shoutStep.
The Five Files of a Workflow Plugin
Here's what you built and why each exists:
| File | Purpose | Required? |
|---|---|---|
steps/shout.ts | Core logic with "use step" | Yes |
index.ts | Plugin definition, registration | Yes |
icon.tsx | Visual identifier in UI | Yes (can be generic) |
credentials.ts | Type safety for secrets | Yes (can be empty) |
test.ts | Connection validation | No |
In Lesson 4, pnpm create-plugin generates all of these. But now you know what each one does.
Solution
The complete plugin code is shown in Phases 1-3 above. The validation check handles edge cases before they cause cryptic errors. In Lesson 5, you'll learn to throw FatalError for unrecoverable problems.
Commit
git add -A
git commit -m "feat(plugins): add shout plugin - first custom plugin"Done
- Step function created with
"use step"directive - Plugin definition with
configFieldsfor the message input pnpm discover-pluginsrun successfully- Plugin appears in action grid
- Workflow runs and logs ALL CAPS message
- Step shows in execution logs with timing
What's Next
You built a plugin by hand. You understand the five files, the registration, the executor wiring. In Lesson 4, you'll use pnpm create-plugin to scaffold an email plugin automatically — then you'll focus on the interesting part: the Resend API integration and credential handling.
Was this helpful?