Extension Recipes
Could not copy markdown source body. Please use "view as markdown".
Practical, copy-pasteable extension patterns for common use cases.
Validation Gate
Section titled “Validation Gate”Block status transitions that violate your workflow rules.
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
Section titled “Post-Create Automation”Add a guidance comment and optional child issues when an issue is created.
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
Section titled “External CLI Integration”Run an external tool and use its output in a hook.
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
Section titled “Config-Driven Behavior”Read config values and parse them into typed data.
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)
Section titled “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.
# Set webhook URL as env var (add to ~/.zshrc to persist)export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../xxx"[extensions.slack-notify]events = "issue:created,issue:status:changed"project_name = "My Project"[extensions.slack-notify]webhook_url = "https://hooks.slack.com/services/T.../B.../xxx"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 for config-driven event filtering and status transition guards.