Writing Custom FP Extensions
A complete guide to building automation plugins for the FP CLI and Desktop app. Hook into the issue lifecycle, automate workflows, and integrate external tools.
What are FP Extensions?
FP extensions are user-defined automation plugins that hook into the issue and comment lifecycle. They work across both the fp CLI and the Desktop app, sharing the same Promise-based API.
.ts / .js files. No manifest, no package.json, no build step required.init function that receives FpExtensionContext. That's the entire contract.Extensions run in two runtimes: Bun (CLI) and an isolated Electron utility process (Desktop). The same extension code works in both — use fp.runtime to branch when needed.
FP_DISABLE_EXTENSIONS=1 to disable if needed.
Where to put extensions
hello-hooks.ts becomes the extension named hello-hooks. Project extensions override global ones when names collide.
Supported file formats: .ts .js .mts .mjs — files ending in .d.ts are skipped.
Your first extension
import type { FpExtensionContext } from "@fiberplane/extensions"; export default function myExtension(fp: FpExtensionContext) { fp.log.info(`[my-extension] loaded on ${fp.runtime}`); fp.on("issue:created", ({ issue }) => { fp.log.info(`New issue: ${issue.id} — ${issue.title}`); }); }
That's it. Save the file, and fp will load it on next run (CLI) or on project open (Desktop). The Desktop app supports live reload — file changes trigger automatic reload with pending pre-hooks drained first.
FpExtensionContext
Every extension receives this single object. It's your entire API surface. Import types from @fiberplane/extensions — a zero-install types package with no runtime dependencies.
"cli" or "desktop"config.toml// Full type signature interface FpExtensionContext { readonly runtime: "cli" | "desktop"; readonly projectDir: string; readonly on: (event, handler) => void; readonly issues: ExtensionIssueContextAccessPromise; readonly comments: ExtensionCommentAccessPromise; readonly secrets: ExtensionSecretsAccessPromise; readonly ui: ExtensionUiAccessPromise; readonly config: ExtensionConfigAccess; readonly log: ExtensionLogger; } // Entry point type type ExtensionInit = (fp: FpExtensionContext) => void | Promise<void>;
Hooks fire automatically through the command layer. Pre-hooks run before the write and can reject it. Post-hooks are fire-and-forget side effects.
Pre-hooks run sequentially in discovery order — the first rejection stops remaining hooks. Post-hooks run in parallel with errors logged as warnings.
Issue Hooks
| Event | Type | Context | Can Reject? |
|---|---|---|---|
issue:creating | Pre | { issue } | Yes |
issue:created | Post | { issue } | — |
issue:updating | Pre | { issue, updates } | Yes |
issue:updated | Post | { issue, updates } | — |
issue:deleting | Pre | { issue } | Yes |
issue:deleted | Post | { issue } | — |
issue:status:changing | Pre | { issue, from, to } | Yes |
issue:status:changed | Post | { issue, from, to } | — |
Comment Hooks
| Event | Type | Context | Can Reject? |
|---|---|---|---|
comment:creating | Pre | { issueId, content } | Yes |
comment:created | Post | { comment, issueId } | — |
comment:deleting | Pre | { comment, issueId } | Yes |
comment:deleted | Post | { comment, issueId } | — |
Rejecting operations from pre-hooks
Return an object with code, message, and optional details to block the operation:
fp.on("issue:status:changing", ({ from, to }) => { if (from === "todo" && to === "done") { return { code: "SKIP_NOT_ALLOWED", message: "Cannot skip directly from todo to done.", }; } });
on() method also accepts arbitrary event strings for forward-compatible registration of future hook events.
Extensions read configuration from the project's .fp/config.toml. Each extension gets its own section under [extensions.<name>]:
# .fp/config.toml project_id = "proj_abc123" prefix = "MYPROJ" # Per-extension settings [extensions.my-extension] model = "gpt-4" max_retries = "3" enabled = true # set false to disable this extension # Disable ALL extensions for this project [extensions] enabled = false
Access config values in your extension:
const model = fp.config.get("model", "gpt-4"); // with default const apiKey = fp.config.get("api_key"); // undefined if not set const retries = fp.config.get<number>("max_retries", 3);
Store sensitive values in the OS keychain instead of plain-text config files. Backed by macOS Keychain and Linux secret-tool — no native Node dependencies. Scoped per extension and project.
// Store a secret securely await fp.secrets.set("api-key", "sk-abc123..."); // Retrieve it const key = await fp.secrets.get("api-key"); if (!key) { fp.log.warn("API key not configured"); return; } // Remove when no longer needed await fp.secrets.delete("api-key");
config.toml gets a "secret:key-name" placeholder.
Typed Issue Fields
Extensions can define custom properties on issues that appear in the Desktop UI alongside built-in fields like status and priority. Properties are registered during init and stored in the issue's Y.Doc — they participate in CRDT sync automatically.
Display types
string in fields.string[] in fields.string in fields.Registering properties
import type { FpExtensionContext } from "@fiberplane/extensions"; export default async function myExtension(fp: FpExtensionContext) { const { properties } = fp.ui; // Select (single-value dropdown) await fp.issues.registerProperty("environment", { label: "Environment", icon: "server", display: properties.select( properties.option("production", { label: "Production", icon: "shield", color: "destructive" }), properties.option("staging", { label: "Staging", icon: "flask-conical", color: "warning" }), properties.option("development", { label: "Development", icon: "code", color: "blue" }), ), }); // Multiselect (multi-value chips) await fp.issues.registerProperty("labels", { label: "Labels", icon: "tags", display: properties.multiselect( properties.option("frontend", { label: "Frontend", icon: "layout", color: "purple" }), properties.option("backend", { label: "Backend", icon: "database", color: "turquoise" }), ), }); // Text (inline editable string) await fp.issues.registerProperty("estimate", { label: "Estimate", icon: "clock", display: properties.text(), }); }
PropertyOptions
| Field | Type | Required | Description |
|---|---|---|---|
label | string | No | Display label for the property row |
icon | string | No | Lucide icon name |
schema | Standard Schema | No | Validator (Zod, Valibot, ArkType compatible) |
display | PropertyDisplay | Yes | How the value renders (select, multiselect, or text) |
PropertyColor palette
13 constrained color tokens for option chips:
Option rendering rules
| icon | color | Renders as |
|---|---|---|
| Yes | Yes | Colored chip with icon |
| Yes | No | Icon + plain label |
| No | Yes | Colored dot + label |
| No | No | Plain text |
Schema validation
Pass any Standard Schema v1 compatible validator. Values are validated on create and update. Unregistered field keys are rejected.
import { z } from "zod"; await fp.issues.registerProperty("priority-score", { label: "Priority Score", icon: "gauge", schema: z.number().min(1).max(10), display: fp.ui.properties.text(), });
title, description, status, priority, parent, dependencies. Field values are stored in the existing Y.Doc — no separate file or migration needed.
Register actions that appear in the Desktop command palette and issue detail view. Also show toast notifications. These are no-ops in the CLI runtime.
fp.ui.registerAction({ id: "my-ext:greet", label: "Say Hello", icon: "hand", keywords: ["hello", "greet"], when: (ctx) => !!ctx.issue, // only show on issue views onExecute: async (ctx) => { await fp.ui.notify("Hello from my extension!", { kind: "success", title: "My Extension", }); }, });
Action options
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier |
label | string | Yes | Display text in command palette |
icon | string | No | Lucide icon name |
keywords | string[] | No | Search/discovery keywords |
when | (ctx) => boolean | No | Conditional visibility predicate |
onExecute | (ctx) => void | Yes | Handler called when triggered |
Notification options
await fp.ui.notify("Operation complete", { title: "My Extension", // optional heading kind: "success", // "info" | "success" | "warning" | "error" });
Full CRUD operations on issues and comments. All writes go through the command layer, which triggers hooks, validation, and sync.
// Create an issue (only title is required) const issue = await fp.issues.create({ title: "Follow-up task", status: "todo", priority: "high", parent: parentIssueId, // optional: make it a sub-task fields: { environment: "staging" }, // optional: custom property values }); // List issues with filters const open = await fp.issues.list({ status: "in-progress" }); const children = await fp.issues.list({ parent: issue.id }); const roots = await fp.issues.list({ parent: null }); // root issues only // Get a specific issue const found = await fp.issues.get("PROJ-42"); // null if not found // Update fields (including custom fields) await fp.issues.update(issue.id, { status: "done", title: "Updated title", fields: { environment: "production" }, }); // Delete await fp.issues.delete(issue.id); // Comments await fp.comments.create(issue.id, "Auto-generated note"); const comments = await fp.comments.list(issue.id); await fp.comments.delete(comments[0].id);
ExtensionIssue shape
interface ExtensionIssue { id: string; title: string; description: string; status: "todo" | "in-progress" | "done"; priority: "low" | "medium" | "high" | "critical" | null; parent: string | null; dependencies: readonly string[]; revisions: Ref | readonly Ref[] | null; // VCS refs (Git sha / JJ changeId) author?: string; createdAt: string; updatedAt: string; fields?: Record<string, unknown>; // custom property values } type Ref = | { readonly _tag: "Git"; readonly sha: string } | { readonly _tag: "JJ"; readonly changeId: string };
ExtensionComment shape
interface ExtensionComment { id: string; issueId: string; author: string; content: string; createdAt: string; }
Auto-post a welcome comment when an issue is created:
import type { FpExtensionContext } from "@fiberplane/extensions"; export default function welcomeBot(fp: FpExtensionContext) { fp.on("issue:created", async ({ issue }) => { await fp.comments.create( issue.id, "Thanks for opening this issue! Please add acceptance criteria.", ); }); }
Enforce allowed status transitions with a pre-hook:
import type { FpExtensionContext } from "@fiberplane/extensions"; export default function transitionGuard(fp: FpExtensionContext) { const rules: Record<string, string[]> = { todo: ["in-progress"], "in-progress": ["done", "todo"], done: ["todo"], }; fp.on("issue:status:changing", ({ from, to }) => { const allowed = rules[from] ?? []; if (!allowed.includes(to)) { return { code: "TRANSITION_BLOCKED", message: `Cannot move from ${from} to ${to}. Allowed: ${allowed.join(", ")}`, }; } }); }
Send a Slack message when an issue is marked done, using secrets for the webhook URL:
import type { FpExtensionContext } from "@fiberplane/extensions"; export default function slackNotify(fp: FpExtensionContext) { fp.on("issue:status:changed", async ({ issue, to }) => { if (to !== "done") return; const webhook = await fp.secrets.get("slack-webhook"); if (!webhook) return; await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `Issue completed: ${issue.title}`, }), }); }); }
Remind the user to associate a revision when completing an issue:
import type { FpExtensionContext } from "@fiberplane/extensions"; export default function revisionReminder(fp: FpExtensionContext) { fp.on("issue:status:changed", ({ issue, to }) => { if (to !== "done") return; console.log( `\n Reminder: associate a revision with ${issue.id} if you just completed work\n` + ` Run: fp issue assign ${issue.id} --rev <commit>\n`, ); }); }
The examples/ directory contains a teaching ladder from simple logging to full agent integrations. Suggested learning order:
| # | Example | Difficulty | What it Demonstrates |
|---|---|---|---|
| 1 | hello-hooks | Beginner | Logging, post-hooks, welcome comments, config access |
| 2 | status-transition-guard | Beginner | Pre-hook validation, blocking invalid transitions |
| 3 | post-create-automation | Beginner | Conditional automation, child issue creation, CSV config |
| 4 | custom-properties | Beginner | All property display types (select, multiselect, text) |
| 5 | quality-gate | Intermediate | Config-driven build checks, running local commands |
| 6 | backlog-researcher | Intermediate | Spawning external CLI (claude), posting research comments |
| 7 | jj-workspace | Advanced | VCS integration (jj bookmarks/workspaces), install hooks |
| 8 | cursor-agent | Advanced | Secrets, UI actions, custom fields, polling, notifications |
examples/<name>/.fp/extensions/ into your project's .fp/extensions/ directory. Also check each example's .fp/config.toml for required config values.
Extensions can import npm packages normally. Module resolution walks upward from the extension file:
For global extensions at ~/.fiberplane/extensions/, resolution starts from that path and walks upward.
bun add <pkg> from the appropriate location, or create a package.json in .fp/extensions/ and run bun install there.
Real-world gotchas discovered building extensions that spawn external processes (like Codex CLI).
Shell out for writes when needed
If the extension API has issues with writes (e.g., yjs_updates table receiving a null ID), fall back to shelling out to the fp CLI. Reads through the extension API are reliable.
// Reads — extension API works reliably const comments = await fp.comments.list(issueId); // Writes — shell out to fp CLI as fallback await runCommand("fp", ["issue", "create", "--title", "..."], fp.projectDir);
Parse CLI output carefully
fp emits timestamp=... log lines on stdout alongside command output. Use specific regexes:
// BAD — matches "03-05T08:" from the timestamp const idMatch = output.match(/(\w+-\w+):/); // GOOD — matches exactly the issue ID const idMatch = output.match(/Created issue (PROJ-\w+)/);
Detach long-running child processes
Post-hooks are fire-and-forget. If you spawn something slow (like an LLM CLI), fp may exit first:
import { spawn } from "node:child_process"; const proc = spawn("codex", ["exec", "--full-auto"], { cwd: fp.projectDir, detached: true, stdio: ["pipe", "ignore", "ignore"], }); proc.stdin.write(prompt); proc.stdin.end(); proc.unref(); // let fp exit without killing the child
fp.projectDir as cwd for all spawned processes so they resolve project-local config and .fp data correctly.
- Enabled by default — Extensions load automatically in both CLI and Desktop. No feature flag needed.
- Runtime branching — Use
fp.runtime("cli"or"desktop") for conditional behavior. UI actions are no-ops in CLI. - Pre-hook errors reject the operation entirely. Post-hook errors are logged as warnings but don't block anything.
- Names come from filenames, not your function name.
my-cool-hook.tsis the extensionmy-cool-hook. - Desktop isolation — Extensions run in a separate utility process with crash recovery (max 3 restarts per minute).
- Live reload — The Desktop app watches extension directories and auto-reloads when files change. Pending pre-hooks are drained before restart.
- Command layer safety — You can't bypass validation or skip activity logging. All
fp.issues.*andfp.comments.*calls go through the same path as the CLI/UI. - Kill-switch — Set
FP_DISABLE_EXTENSIONS=1env var to force-disable all extensions instantly. - Project opt-out — Add
[extensions] enabled = falseto.fp/config.tomlto skip extensions for a specific project. - Custom fields validation — Field values are validated against registered schemas on
createandupdate. Unregistered field keys are rejected. - Built-in key protection —
registerProperty()rejects keys that collide with built-in attributes (status,priority,parent,dependencies, etc.). - Node-compatible code only — Write code compatible with both Bun and Node.js. Use
node:child_process,node:fs/promises,node:path. Avoid Bun-only globals. - Import types for DX — Use
import type { FpExtensionContext } from "@fiberplane/extensions"for autocomplete. Zero-install, no runtime dependency.