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.

enabled by default TypeScript / JavaScript CLI + Desktop Promise-based API @fiberplane/extensions
1 Overview

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.

Simple files
Plain .ts / .js files. No manifest, no package.json, no build step required.
Default export
Export a single init function that receives FpExtensionContext. That's the entire contract.
Promise API
Write normal async/await code. You never interact with Effect directly — a bridge layer handles it.
Safe by design
All writes flow through the command layer: validation, activity logging, and sync happen automatically.

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.

2 Getting Started
Enabled by default: As of March 2026, extensions are enabled by default in both CLI and Desktop. No feature flag required. Use FP_DISABLE_EXTENSIONS=1 to disable if needed.

Where to put extensions

~/.fiberplane/extensions/ # global — available in all projects my-extension.ts another-one.ts <repo>/.fp/extensions/ # project-local — override global by name project-hook.ts complex-ext/ index.ts # directory with index file also works
Naming rule: The extension name comes from the filename (or directory name), not your code. 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

typescript
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.

3 The Extension API

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.

runtime
Which runtime: "cli" or "desktop"
projectDir
Absolute path to the project root directory
on(event, handler)
Register lifecycle hooks on issue/comment events
issues
Issue CRUD + custom property registration
get · list · create · update · delete · registerProperty
comments
Comment CRUD
create · list · delete
secrets
OS keychain-backed secret storage
get · set · delete
ui
Desktop actions, toasts & property display builders
registerAction · notify · properties
config
Read values from config.toml
get(key, default?)
log
Structured logging (auto-prefixed with extension name)
debug · info · warn · error
typescript
// 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>;
4 Lifecycle Hooks

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-hookvalidation
→
Write opcommand layer
→
Post-hookside 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

EventTypeContextCan Reject?
issue:creatingPre{ issue }Yes
issue:createdPost{ issue }—
issue:updatingPre{ issue, updates }Yes
issue:updatedPost{ issue, updates }—
issue:deletingPre{ issue }Yes
issue:deletedPost{ issue }—
issue:status:changingPre{ issue, from, to }Yes
issue:status:changedPost{ issue, from, to }—

Comment Hooks

EventTypeContextCan Reject?
comment:creatingPre{ issueId, content }Yes
comment:createdPost{ comment, issueId }—
comment:deletingPre{ comment, issueId }Yes
comment:deletedPost{ comment, issueId }—

Rejecting operations from pre-hooks

Return an object with code, message, and optional details to block the operation:

typescript
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.",
    };
  }
});
Forward-compatible hooks: The on() method also accepts arbitrary event strings for forward-compatible registration of future hook events.
5 Configuration

Extensions read configuration from the project's .fp/config.toml. Each extension gets its own section under [extensions.<name>]:

toml
# .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:

typescript
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);
6 Secrets (OS Keychain)

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.

typescript
// 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");
Desktop integration: In the Desktop app, users can set extension secrets through the Settings UI. The actual value is stored in the keychain, while config.toml gets a "secret:key-name" placeholder.
7 Custom Properties

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

select
Single-value dropdown picker. Stored as a string in fields.
multiselect
Multi-value chip picker. Stored as a string[] in fields.
text
Freeform inline-editable input. Stored as a string in fields.

Registering properties

typescript
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

FieldTypeRequiredDescription
labelstringNoDisplay label for the property row
iconstringNoLucide icon name
schemaStandard SchemaNoValidator (Zod, Valibot, ArkType compatible)
displayPropertyDisplayYesHow the value renders (select, multiselect, or text)

PropertyColor palette

13 constrained color tokens for option chips:

neutral purple pink turquoise blue yellow orange mint red lime success warning destructive

Option rendering rules

iconcolorRenders as
YesYesColored chip with icon
YesNoIcon + plain label
NoYesColored dot + label
NoNoPlain text

Schema validation

Pass any Standard Schema v1 compatible validator. Values are validated on create and update. Unregistered field keys are rejected.

typescript
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(),
});
Important constraints: Register properties during init, not inside hook handlers. Keys are append-only per process lifetime. Cannot collide with built-in attributes: title, description, status, priority, parent, dependencies. Field values are stored in the existing Y.Doc — no separate file or migration needed.
8 UI Actions & Notifications

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.

typescript
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

PropertyTypeRequiredDescription
idstringYesUnique identifier
labelstringYesDisplay text in command palette
iconstringNoLucide icon name
keywordsstring[]NoSearch/discovery keywords
when(ctx) => booleanNoConditional visibility predicate
onExecute(ctx) => voidYesHandler called when triggered

Notification options

typescript
await fp.ui.notify("Operation complete", {
  title: "My Extension",          // optional heading
  kind: "success",                // "info" | "success" | "warning" | "error"
});
9 Issue & Comment CRUD

Full CRUD operations on issues and comments. All writes go through the command layer, which triggers hooks, validation, and sync.

typescript
// 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
typescript
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
typescript
interface ExtensionComment {
  id: string;
  issueId: string;
  author: string;
  content: string;
  createdAt: string;
}
10 Practical Recipes
1 Welcome Comment Bot

Auto-post a welcome comment when an issue is created:

typescript
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.",
    );
  });
}
2 Status Transition Guard

Enforce allowed status transitions with a pre-hook:

typescript
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(", ")}`,
      };
    }
  });
}
3 Slack Notification on Done

Send a Slack message when an issue is marked done, using secrets for the webhook URL:

typescript
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}`,
      }),
    });
  });
}
4 Revision Reminder

Remind the user to associate a revision when completing an issue:

typescript
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`,
    );
  });
}
11 Bundled Examples

The examples/ directory contains a teaching ladder from simple logging to full agent integrations. Suggested learning order:

#ExampleDifficultyWhat it Demonstrates
1hello-hooksBeginnerLogging, post-hooks, welcome comments, config access
2status-transition-guardBeginnerPre-hook validation, blocking invalid transitions
3post-create-automationBeginnerConditional automation, child issue creation, CSV config
4custom-propertiesBeginnerAll property display types (select, multiselect, text)
5quality-gateIntermediateConfig-driven build checks, running local commands
6backlog-researcherIntermediateSpawning external CLI (claude), posting research comments
7jj-workspaceAdvancedVCS integration (jj bookmarks/workspaces), install hooks
8cursor-agentAdvancedSecrets, UI actions, custom fields, polling, notifications
Getting examples: Copy extension files from examples/<name>/.fp/extensions/ into your project's .fp/extensions/ directory. Also check each example's .fp/config.toml for required config values.
12 Dependencies & Node Modules

Extensions can import npm packages normally. Module resolution walks upward from the extension file:

<repo>/ .fp/extensions/ my-extension.ts # your extension node_modules/ # 1st — checked first .fp/ node_modules/ # 2nd node_modules/ # 3rd — project root

For global extensions at ~/.fiberplane/extensions/, resolution starts from that path and walks upward.

Tip: If your extension needs a dependency, run bun add <pkg> from the appropriate location, or create a package.json in .fp/extensions/ and run bun install there.
13 Practical Learnings

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.

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

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

typescript
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
Always set cwd: Pass fp.projectDir as cwd for all spawned processes so they resolve project-local config and .fp data correctly.
14 Tips & Gotchas
Updated March 2026 · Source: packages/extensions/src/context.ts · packages/extensions/src/models.ts · docs/architecture/extensions.md