| name | fiftyone-develop-plugin |
|---|---|
| description | Develops custom FiftyOne plugins (operators and panels) from scratch. Use when creating plugins, extending FiftyOne with custom operators, building interactive panels, or integrating external APIs. |
Step 1: Decide what you're building.
| Goal | Type | Read next |
|---|---|---|
| Run a computation, process samples, trigger actions | Operator | PYTHON-OPERATOR.md |
| Persistent panel in the grid, simple/form UI | Python Panel | PYTHON-PANEL.md |
| Persistent panel in the grid, rich/interactive UI | Hybrid Panel | HYBRID-PLUGINS.md + JAVASCRIPT-PANEL.md |
| Panel that opens inside the sample modal | JS Modal Panel | JAVASCRIPT-PANEL.md |
Step 2: Read that file NOW — before generating any code. Each file contains patterns that are not in SKILL.md. Missing them causes the most common failures (wrong default= usage, handler binding errors, panel not appearing in App).
If something isn't rendering or you hit an error: Read TROUBLESHOOTING.md for the one-shot diagnostic command, reload vs restart table, and the top 5 error patterns with exact fixes.
For input types, Panel UI components reference, and a minimal working example: Read QUICK-REFERENCE.md.
Step 3: Clone the official plugins repo for reference patterns:
git clone https://github.com/voxel51/fiftyone-plugins.git /tmp/fiftyone-plugins 2>/dev/null || trueStep 4: Install your plugin by placing it (or symlinking it) in the plugins directory:
PLUGINS_DIR=$(python -c "import fiftyone as fo; print(fo.config.plugins_dir)")
# Convention: use underscores in the directory name (e.g. myorg_my_plugin) for shell compatibility
# The @org/name URI lives only inside fiftyone.yml — the directory name doesn't need to match
ln -s /path/to/my_plugin "$PLUGINS_DIR/my_plugin"Step 5: Launch in debug mode so you see all server logs:
fiftyone app debug <dataset-name>ALWAYS follow these rules:
Ask clarifying questions. Never assume what the plugin should do.
Present file structure and design. Get user approval before generating code.
# Clone official plugins for reference
git clone https://github.com/voxel51/fiftyone-plugins.git /tmp/fiftyone-plugins 2>/dev/null || true
# Search for similar patterns
grep -r "keyword" /tmp/fiftyone-plugins/plugins/ --include="*.py" -llist_plugins(enabled=True)
list_operators(builtin_only=False)
get_operator_schema(operator_uri="@voxel51/brain/compute_similarity")# Get plugins directory
PLUGINS_DIR=$(python -c "import fiftyone as fo; print(fo.config.plugins_dir)")
# Develop plugin in plugins directory
cd $PLUGINS_DIR/my-pluginWrite tests:
- Python:
pytestfor operators/panels - JavaScript:
vitestfor React components
Verify in FiftyOne App before done.
If the plugin wraps non-trivial processing (model inference, image transforms, external APIs), verify that logic works as standalone Python before integrating it into the plugin:
# Test independently first
result = my_processing_fn(sample_path)
print(result) # confirm correctness
# Only then wrap in execute()
def execute(self, ctx):
result = my_processing_fn(ctx.dataset.first().filepath)
...This prevents debugging two systems (plugin framework + custom logic) at once.
Run server separately to see logs:
# Terminal 1: Python logs
python -m fiftyone.server.main
# Terminal 2: Browser at localhost:5151 (JS logs in DevTools console)fiftyone app debug # logs printed to shell; use with a dataset:
fiftyone app debug <dataset-name>For automated iteration, use Playwright e2e tests:
npx playwright testRefine until the plugin works as expected.
# WRONG — triggers a render loop: schema changes → re-render → schema changes → ...
def render(self, ctx):
panel = types.Object()
panel.str("threshold", default=ctx.panel.get_state("threshold")) # ← NEVER do this
# CORRECT — pass a literal; read state separately for display logic
def render(self, ctx):
panel = types.Object()
panel.str("threshold", default="0.5") # literal only
current = ctx.panel.get_state("threshold", "0.5") # read separately if neededThe default= value is part of the schema the frontend uses to decide whether to re-render. If it changes between renders (because state changed), the frontend re-dispatches, which re-renders, infinitely.
# WRONG — @staticmethod strips __self__, breaking the handler URI silently
@staticmethod
def on_click(ctx):
...
panel.btn("btn", label="Go", on_click=MyPanel.on_click) # ← broken
# CORRECT — regular instance method
def on_click(self, ctx):
...
panel.btn("btn", label="Go", on_click=self.on_click) # ← worksThe framework reads handler.__self__.uri to build the operator-trigger URI. A @staticmethod has no __self__, so the wiring silently fails and button clicks do nothing.
# WRONG — plugin loads, no error, but ZERO operators appear in the App
import fiftyone.operators as foo
class MyPanel(foo.Panel):
...
# forgot register() entirely — or it raises an exception silently# CORRECT — must explicitly register every operator and panel
def register(p):
p.register(MyPanel)
p.register(MyOperator) # each class must be listedThe plugin loader calls module.register(self) after importing __init__.py. If the function is missing, raises an exception, or simply doesn't call p.register(YourClass) for a class, that class is never inserted into the operator registry. The App shows the plugin as enabled in list_plugins() — no error, no warning — but the operator or panel is completely invisible. This is the most common cause of "my panel doesn't appear in the + New panel menu."
Convention on directory naming: Use myorg_my_plugin (underscores, no @ or -) for clarity and shell compatibility. This is convention, not a hard requirement — the loader derives the module name from fiftyone.yml's name: field and sanitizes it automatically. The @org/name URI belongs in fiftyone.yml; the directory just needs to contain that file.
# Chain operators (non-delegated operators only, in execute() only, fire-and-forget)
ctx.trigger("@plugin/other_operator", params={...})
# UI operations
ctx.ops.notify("Done!")
ctx.ops.set_progress(progress=0.5) # keyword required — first positional arg is label, not progress# Use ctx.target_view() to respect user's current selection and filters
view = ctx.target_view()
# ctx.dataset - Full dataset (use when explicitly exporting all)
# ctx.view - Filtered view (use for read-only operations)
# ctx.target_view() - Filtered + selected samples (use for exports/processing)# ctx.store() already scopes by dataset_id internally — no need to embed dataset name.
# Use a plugin-unique name to avoid collisions with other plugins on the same dataset.
def _get_store_key(self):
return self.config.name.split("/")[-1] # e.g. "my_panel"
store = ctx.store(self._get_store_key())# ctx.panel.state - Transient (resets when panel reloads)
# ctx.store() - Persistent (survives across sessions)
def on_load(self, ctx):
ctx.panel.state.selected_tab = "overview" # Transient
store = ctx.store(self._get_store_key())
ctx.panel.state.config = store.get("user_config") or {} # PersistentUse for operations that: process >100 samples or take >1 second.
@property
def config(self):
return foo.OperatorConfig(
name="heavy_operator",
allow_delegated_execution=True,
default_choice_to_delegated=True,
)@property
def config(self):
return foo.OperatorConfig(
name="progress_operator",
execute_as_generator=True,
)
def execute(self, ctx):
total = len(ctx.target_view())
for i, sample in enumerate(ctx.target_view()):
# Process sample...
yield ctx.trigger("set_progress", {"progress": (i+1)/total})
yield {"status": "complete"}Use Custom Runs for operations needing reproducibility and history tracking:
# Create run key (must be valid Python identifier - use underscores, not slashes)
run_key = f"my_plugin_{self.config.name}_v1_{timestamp}"
# Initialize and register
run_config = ctx.dataset.init_run(operator=self.config.name, params=ctx.params)
ctx.dataset.register_run(run_key, run_config)See PYTHON-OPERATOR.md for full Custom Runs pattern. See EXECUTION-STORE.md for advanced caching patterns. See HYBRID-PLUGINS.md for Python + JavaScript communication.
Understand what the user needs to accomplish:
- "What problem are you trying to solve?"
- "What should the user be able to do?" (user's perspective)
- "What information does the user provide?"
- "What result does the user expect to see?"
- "Any external data sources or services involved?"
- "How will this fit into the user's workflow?"
- Search existing plugins for similar patterns
- Choose the right panel architecture:
- Modal panel (appears in sample modal) → Use JS Panel + Python Operators. See JAVASCRIPT-PANEL.md — Modal Panels. Do NOT use
composite_view=True— it produces "Unsupported View" errors for modal panels. - Grid panel with rich UI → Use hybrid (Python + JavaScript). See HYBRID-PLUGINS.md.
- Grid panel with simple UI → Use Python-only. See PYTHON-PANEL.md.
- Modal panel (appears in sample modal) → Use JS Panel + Python Operators. See JAVASCRIPT-PANEL.md — Modal Panels. Do NOT use
- Create plan with:
- Plugin name (
@org/plugin-name) - File structure
- Operator/panel specs
- Input/output definitions
- Plugin name (
- Get user approval before coding
See PLUGIN-STRUCTURE.md for file formats.
Before writing any code, read the reference file for your plugin type:
| Building… | Read this file first |
|---|---|
| Operator | PYTHON-OPERATOR.md — input types, execution patterns, placement |
| Python panel | PYTHON-PANEL.md — layout containers, state/data/store, event lifecycle |
| JS/hybrid panel | JAVASCRIPT-PANEL.md + HYBRID-PLUGINS.md — React components, Python↔JS bridge |
| Persistent storage | EXECUTION-STORE.md — TTL caching, cross-session state |
| Something not rendering / errors | TROUBLESHOOTING.md — one-shot diagnostic, top 5 errors, reload vs restart |
| Input types, Panel UI components, minimal example | QUICK-REFERENCE.md — lookup tables and copy-paste starting point |
Create these files:
| File | Required | Purpose |
|---|---|---|
fiftyone.yml |
Yes | Plugin manifest |
__init__.py |
Yes | Python operators/panels |
requirements.txt |
If deps | Python dependencies |
package.json |
JS only | Node.js metadata |
src/index.tsx |
JS only | React components |
For JavaScript panels with rich UI: Invoke the fiftyone-voodo-design skill for VOODO components (buttons, inputs, toasts, design tokens). VOODO is FiftyOne's official React component library.
list_plugins(enabled=True) # Should show your plugin
list_operators() # Should show your operatorsIf not found: Check fiftyone.yml syntax, Python syntax errors, restart App.
get_operator_schema(operator_uri="@myorg/my-operator")Verify inputs/outputs match your expectations.
set_context(dataset_name="test-dataset")
launch_app()
execute_operator(operator_uri="@myorg/my-operator", params={...})Common failures:
- "Operator not found" → Check fiftyone.yml operators list
- "Missing parameter" → Check resolve_input() required fields
- "Execution error" → Check execute() implementation
- Get user feedback
- Fix issues (sync source and plugins directory if separate)
- Restart App if needed
- Repeat until working
For the full input types table, Panel UI components, config option tables, and a minimal working example:
→ Read QUICK-REFERENCE.md now.
Run this first whenever a plugin or operator is missing from the App:
curl -s -X POST http://localhost:5151/operators \
-H 'Content-Type: application/json' -d '{}' \
| python -c "
import json, sys
d = json.load(sys.stdin)
errors = d.get('errors', [])
if errors:
print('PLUGIN LOAD ERRORS:')
for e in errors: print(' -', e)
else:
print('No load errors. Operators found:', len(d.get('operators', [])))
"This returns every error the plugin loader captured — Python syntax errors, import failures, missing register() calls — in one shot. Check this before debugging anything else.
Plugin not appearing:
- Check
fiftyone.ymlexists in plugin root - Verify location:
~/.fiftyone/plugins/ - Check
register(p)is present and callsp.register()for each class — see Gotcha 3 above - Check for Python syntax errors (run the diagnostic above)
- Restart FiftyOne App
Panel not appearing in the "+ New panel" menu:
- Plugin must load cleanly (check diagnostic above)
PanelConfig.namemust match the entry underpanels:infiftyone.yml- Restart App after adding a new panel — the panel registry is built at startup
Operator not found:
- Verify operator listed in
fiftyone.yml - Check
register()function - Run
list_operators()to debug
Secrets not available:
- Add to
fiftyone.ymlundersecrets: - Set environment variables before starting FiftyOne
Render loop / thousands of operator calls per minute:
- Almost always caused by
default=ctx.panel.get_state(...)— see Gotcha 1 above - Fix: replace with a string/number literal in
default=
# For executing operators outside of FiftyOne App context
import fiftyone.operators as foo
result = foo.execute_operator(operator_uri, ctx, **params)