Vercel Logo

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.

Mental Model: Steps and Flow

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

  1. Write the step function (the core logic)
  2. Create the plugin definition (tell the system it exists)
  3. 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.

There's a Template for This

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.

Reflection Prompt

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

Write the step function (plugins/shout/steps/shout.ts):

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 logging
  • stepHandler — 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):

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 panel
  • stepFunction → the exported function name from your step file

Add the icon (plugins/shout/icon.tsx):

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):

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

This does three things automatically:

Loading diagram...
  1. Updates plugins/index.ts — adds import "./shout" so your plugin registers on startup
  2. Updates lib/step-registry.ts — adds the step importer so the executor can find your step
  3. Updates lib/types/integration.ts — adds "shout" to the IntegrationType union
Don't Edit Generated Files

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 dev

Try It

  1. Open the workflow builder
  2. Click the + button to add an action
  3. Find Shout Message in the action grid
  4. Configure the message field: hello workflow
  5. 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
Question

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

Check the output — it should say "Found 1 plugin(s): shout"

2. Did you restart the dev server?

pnpm dev

3. Check the generated files:

  • plugins/index.ts should have import "./shout";
  • lib/step-registry.ts should 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:

FilePurposeRequired?
steps/shout.tsCore logic with "use step"Yes
index.tsPlugin definition, registrationYes
icon.tsxVisual identifier in UIYes (can be generic)
credentials.tsType safety for secretsYes (can be empty)
test.tsConnection validationNo

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 configFields for the message input
  • pnpm discover-plugins run 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.