Practical, copy-pasteable extension patterns for common use cases.

## Validation Gate

Block status transitions that violate your workflow rules.

```ts
import type { ExtensionInit } from "@fiberplane/extensions";

const init: ExtensionInit = (fp) => {
  const requiredLabels = new Set(
    fp.config.get("required_labels", "").split(",").filter(Boolean),
  );

  fp.on("issue:status:changing", async ({ issue, from, to }) => {
    if (to !== "done" || requiredLabels.size === 0) {
      return undefined;
    }

    const labels = (issue.properties?.labels as string[]) ?? [];
    const missing = [...requiredLabels].filter((l) => !labels.includes(l));
    if (missing.length > 0) {
      return {
        code: "MISSING_LABELS",
        message: `Add required labels before marking done: ${missing.join(", ")}`,
      };
    }
    return undefined;
  });
};

export default init;
```

## Post-Create Automation

Add a guidance comment and optional child issues when an issue is created.

```ts
import type { ExtensionInit } from "@fiberplane/extensions";

const init: ExtensionInit = (fp) => {
  fp.on("issue:created", async ({ issue }) => {
    try {
      await fp.comments.create(
        issue.id,
        "Remember to add acceptance criteria before starting work.",
      );
    } catch (err) {
      fp.log.warn(`Failed to add guidance comment: ${err}`);
    }
  });
};

export default init;
```

## External CLI Integration

Run an external tool and use its output in a hook.

```ts
import type { ExtensionInit } from "@fiberplane/extensions";
import { spawn } from "node:child_process";
import { resolve } from "node:path";

function runCommand(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    const proc = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
    let stdout = "";
    let stderr = "";
    proc.stdout.on("data", (d) => { stdout += d; });
    proc.stderr.on("data", (d) => { stderr += d; });
    proc.on("error", reject);
    proc.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
  });
}

const init: ExtensionInit = (fp) => {
  fp.on("issue:status:changing", async ({ issue, to }) => {
    if (to !== "done") {
      return undefined;
    }

    const result = await runCommand("make", ["test"], fp.projectDir);
    if (result.code !== 0) {
      return {
        code: "TESTS_FAILED",
        message: `Tests must pass before marking done. Exit code: ${result.code}`,
      };
    }
    return undefined;
  });
};

export default init;
```

## Config-Driven Behavior

Read config values and parse them into typed data.

```ts
import type { ExtensionInit } from "@fiberplane/extensions";

const init: ExtensionInit = (fp) => {
  const blockedStatuses = new Set(
    fp.config.get("blocked_transitions_from", "").split(",").filter(Boolean),
  );
  const dryRun = ["true", "1", "yes"].includes(
    fp.config.get("dry_run", "false").toLowerCase(),
  );

  fp.on("issue:status:changing", ({ from, to }) => {
    if (blockedStatuses.has(from)) {
      if (dryRun) {
        fp.log.warn(`Dry run: would block transition from ${from} to ${to}`);
        return undefined;
      }
      return {
        code: "BLOCKED_TRANSITION",
        message: `Transitions from '${from}' are not allowed`,
      };
    }
    return undefined;
  });
};

export default init;
```

## Webhook Notification (Slack)

Send a Slack message whenever an issue is created or changes status. Set the webhook URL as an environment variable or in local extension config, and configure which events to notify on.

```bash
# Set webhook URL as env var (add to ~/.zshrc to persist)
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../xxx"
```

```toml
# .fp/config.toml
[extensions.slack-notify]
events = "issue:created,issue:status:changed"
project_name = "My Project"
```

```toml
# .fp/config.local.toml
[extensions.slack-notify]
webhook_url = "https://hooks.slack.com/services/T.../B.../xxx"
```

```ts
import type { ExtensionInit } from "@fiberplane/extensions";

const init: ExtensionInit = async (fp) => {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL ?? fp.config.get("webhook_url", "");
  if (!webhookUrl) {
    fp.log.warn("No Slack webhook URL configured.");
    return;
  }

  const projectName = fp.config.get("project_name", "");
  const prefix = projectName ? `*[${projectName}]*` : "📋";

  fp.on("issue:created", async ({ issue }) => {
    const text = `${prefix} 🆕 *${issue.id}*: ${issue.title}`;
    await fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text,
        blocks: [{
          type: "section",
          text: { type: "mrkdwn", text },
        }],
      }),
    }).catch((err) => fp.log.warn(`Slack failed: ${err}`));
  });

  fp.on("issue:status:changed", async ({ issue, from, to }) => {
    const emoji = to === "done" ? "✅" : to === "in-progress" ? "🔄" : "📝";
    const text = `${prefix} ${emoji} *${issue.id}* \`${from}\` → \`${to}\`\n${issue.title}`;
    await fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text,
        blocks: [{
          type: "section",
          text: { type: "mrkdwn", text },
        }],
      }),
    }).catch((err) => fp.log.warn(`Slack failed: ${err}`));
  });
};

export default init;
```

The same pattern works for Discord (`{ content: "text" }`), Microsoft Teams, or any webhook service — just change the payload shape. See the full [slack-notify example](https://github.com/fiberplane/fp/tree/main/examples/slack-notify) for config-driven event filtering and status transition guards.
