---
title: "Build Your First Plugin"
description: "Build a simple plugin to learn how plugins work before adding API complexity. Understand the plugin folder pattern."
canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/first-plugin"
md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/first-plugin.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-09T07:50:13.467Z"
content_type: "lesson"
course: "visual-workflow-builder-on-vercel"
course_title: "Build Visual Workflow Plugins on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Build Your First Plugin

# 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. The starter stays intentionally minimal; plugin discovery is what makes new integrations show up. Then in the next lesson, you'll use `pnpm create-plugin` to scaffold automatically.

\*\*Note: 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.

\*\*Note: 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:\*\* 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:**

```bash
mkdir -p plugins/shout/steps
```

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

```typescript title="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](https://useworkflow.dev/docs/foundations/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`):

```typescript title="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`):

```tsx title="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`):

```typescript title="plugins/shout/credentials.ts"
export type ShoutCredentials = {
  // No credentials needed for this plugin
};
```

### Phase 3: Registration (Auto-Discovery)

Run the plugin discovery script:

```bash
pnpm discover-plugins
```

This does three things automatically:

```mermaid
flowchart TB
    A[pnpm discover-plugins] --> B[Scan plugins/*/]
    B --> C[plugins/index.ts]
    B --> D[lib/step-registry.ts]
    B --> E[lib/types/integration.ts]
```

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 generated `IntegrationType` union

\*\*Warning: 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`, and the starter now treats those generated files as the source of truth. Your changes will be overwritten.

**Restart the dev server** (new files require restart):

```bash
# 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

```yaml
quiz:
  question: "The action ID 'shout/shout' comes from combining which two fields in the plugin definition?"
  choices:
    - id: "a"
      text: "label + description"
    - id: "b"
      text: "type + slug"
    - id: "c"
      text: "stepFunction + stepImportPath"
    - id: "d"
      text: "category + label"
  correctAnswerId: "b"
  feedback: "{\n    correct: \"Right. The full action ID is '[type]/[slug]'. In your plugin: type='shout', slug='shout' → 'shout/shout'. This is what the executor checks.\",\n    incorrect: \"The action ID format is '[type]/[slug]'. Check your plugin definition — type is the integration identifier, slug is the specific action.\"\n  }"
```

## 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?**

```bash
pnpm discover-plugins
```

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

**2. Did you restart the dev server?**

```bash
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:**

```typescript
// 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

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

\*\*Side Quest: Build the Reverse Plugin\*\*


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
