Extension Recipes
Could not copy markdown source body. Please use "view as markdown".
Practical, copy-pasteable extension patterns for common use cases.
Customizing the Default Labels Extension
Section titled “Customizing the Default Labels Extension”fp ships a bundled Labels extension that registers a labels multiselect property with a set of suggested options (frontend, backend, infra, design, security, perf). It’s seeded into ~/.fiberplane/extensions/labels/ the first time fp loads extensions.
To change the available labels, edit ~/.fiberplane/extensions/labels/index.ts directly. The seeder never overwrites an existing directory, so your edits are safe. Changes take effect on the next fp command or desktop restart. (To reset to defaults, delete the labels/ directory and run any command that loads extensions, e.g. fp tree.)
import type { FpExtensionContext } from "@fiberplane/extensions";
// Suggested labels shown in the multiselect dropdown. Any string value is// accepted — add, remove, or restyle entries to fit your project.const suggestions = (fp: FpExtensionContext) => [ fp.ui.properties.option("frontend", { label: "Frontend", icon: "layout", color: "blue" }), fp.ui.properties.option("backend", { label: "Backend", icon: "database", color: "purple" }), fp.ui.properties.option("infra", { label: "Infra", icon: "cloud", color: "turquoise" }), fp.ui.properties.option("design", { label: "Design", icon: "palette", color: "pink" }), fp.ui.properties.option("security", { label: "Security", icon: "lock", color: "orange" }), fp.ui.properties.option("perf", { label: "Performance", icon: "zap", color: "yellow" }), // Add your own: fp.ui.properties.option("docs", { label: "Documentation", icon: "book", color: "yellow" }),];
export default async function labels(fp: FpExtensionContext) { await fp.issues.registerProperty("labels", { label: "Labels", icon: "tags", display: fp.ui.properties.multiselect(...suggestions(fp)), });}Each option takes an icon (any Lucide icon name) and a color. Available colors: neutral, purple, pink, turquoise, blue, yellow, orange, mint, red, lime, success, warning, destructive.
To enforce a fixed set of labels rather than just suggesting them, pass a Standard Schema validator (zod, valibot, arktype, etc.) — values that don’t match are rejected on write:
import type { FpExtensionContext } from "@fiberplane/extensions";import { z } from "zod";
const ALLOWED = ["frontend", "backend", "infra", "design", "security", "perf"] as const;const labelsSchema = z.array(z.enum(ALLOWED));
export default async function labels(fp: FpExtensionContext) { await fp.issues.registerProperty("labels", { label: "Labels", icon: "tags", schema: labelsSchema, display: fp.ui.properties.multiselect( ...ALLOWED.map((value) => fp.ui.properties.option(value)), ), });}Without a schema, any string value is accepted and stored — values that don’t match a display option render as plain text.
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.