# 3LC Compute Service — Plugin Development Guide
## Overview
The 3LC Compute Service uses a plugin architecture where each feature (training, import, export, insights, etc.) is a self-contained plugin. Plugins provide:
- **Backend logic** — Python code running in the Compute Service
- **UI fragment** — Self-contained HTML+CSS+JS served to the browser
- **REST endpoints** — Optional custom API routes
- **Job reporting** — Optional progress tracking for long-running tasks
The frontend has **zero knowledge** of any specific plugin. It discovers plugins at runtime via the `/api/plugins/` endpoint and renders their UI generically.
> **Porting an existing plugin** to the current contract? This guide documents the contract in
> full — the main changes to make are adopting `run_job(ctx)` for long-running work, relative
> Litestar route handlers for custom endpoints, and the generic `job_update` channel for UI
> updates (all covered below).
**Important:** Plugins must **not** access the Object Service directly. The Object Service may not be reachable from the plugin's environment. All data access should go through the Compute Service, which uses the `tlc` SDK server-side.
---
## Architecture
```
Browser Compute Service (port 5020)
┌─────────────────┐ ┌──────────────────────────────┐
│ plugin-loader.js│───GET────→│ /api/plugins/ │ ← discovery
│ │ │ /api/plugins/manifest/{id} │
│ │───GET────→│ /api/plugins/{id}/ui │ ← UI fragment
│ │ │ /api/plugins/{id}/compute │ ← generic compute
│ PLUGIN_API │───────── →│ /api/plugins/{id}/* │ ← custom routes
│ bridge object │ │ │
└─────────────────┘ │ ┌──────────────────────────┐│
│ │ plugin.toml (manifest) ││ ← all metadata
│ │ + ComputePlugin subclass││
│ │ ComputePlugin (ABC) ││
│ │ ├── get_ui_fragment() ││ ← abstract
│ │ ├── compute() ││ ← override (default)
│ │ ├── id ││ ← host-stamped
│ │ ├── run_job(ctx) ││ ← override (default)
│ │ └── get_route_handlers()││ ← override (default)
│ └──────────────────────────┘│
└──────────────────────────────┘
```
The host owns the job lifecycle: a plugin only *runs* a job (`run_job(ctx)`);
listing, progress fan-out, and cancellation are generic and host-provided. There
is no `get_active_jobs()` / `cancel_job()` on the contract — see
[Long-Running Jobs](#long-running-jobs-run_jobctx).
**Flow:**
1. Frontend calls `GET /api/plugins/` → gets manifests for all plugins
2. Sidebar and action buttons are rendered from manifests (no hardcoded plugin knowledge)
3. When user opens a plugin, frontend calls `GET /api/plugins/{id}/ui` → gets HTML fragment
4. Fragment is injected into the page with a `PLUGIN_API` bridge object
5. Plugin JS uses `PLUGIN_API` to access auth, API clients, Chart.js, SocketIO, etc.
---
## Plugin Types
| `display_mode` | Where it appears | Example |
|---|---|---|
| `sidebar` | Left navigation panel, grouped by `section` | Import, Export, YOLO, SAM3, timm |
| `action` | Action buttons on resource pages (tables, runs) | Merge (2 tables), Run Insights (1+ runs) |
| `hidden` | Not shown in UI; API-only (routes still registered) | Table Statistics (used by project detail inline) |
---
## Step-by-Step: Creating a Plugin
### 1. Create the plugin directory
```
tlc_plugin_my_plugin/ # the default shape: a standalone venv-isolated package
├── plugin.toml # Manifest — ALL metadata (id, name, ui, runtime)
├── __init__.py # Plugin object — behavior only, no metadata, no register()
├── ui.html # UI fragment (HTML + CSS + JS)
├── routes.py # Custom REST controller (optional — config CRUD, etc.)
├── compute.py # Pure compute lifted by run_job(ctx) (optional)
└── ... # All plugin code lives here
```
### 2. Write the manifest
All metadata lives in a manifest — a standalone `plugin.toml` next to `__init__.py`. The host
reads this **without importing** the plugin, builds a "card" from it, and uses it as the single
source of truth for listing, routing, GPU/CPU classification, SocketIO wiring, and auth-exempt
paths. (`read_manifest()` also accepts a `[tool.tlc-compute]` table in a plugin's `pyproject.toml`
— it checks `plugin.toml` first.)
A plugin keeps the **same `plugin.toml`** for metadata and adds a separate
`pyproject.toml` alongside it that declares only its venv's dependencies (no
`[tool.tlc-compute]` table there) — see the `timm` / `sam3` / `yolo` plugins for the canonical
layout.
```toml
# plugin.toml — the single source of truth for this plugin's metadata.
# The host loads the plugin via runtime.entrypoint; there is no register()
# call at import and no metadata on the plugin class.
id = "my-plugin" # URL-safe slug
name = "My Plugin" # Display name
description = "Analyzes table data quality."
version = "1.0.0"
min_service_version = "0.1.0" # Minimum compute service version required
icon = "🔍" # Fallback emoji
# 16x16 SVG, inline in the manifest:
icon_svg = ''
[ui]
display_mode = "sidebar" # sidebar | action | hidden
section = "Tools" # Sidebar section label
compatible_with = ["table"] # Resource types this acts on
input_types = ["table"] # What it consumes
output_types = [] # What it produces (empty = analysis only)
priority = 50 # Sort order in sidebar (higher = first)
quick_action = false # Show in dashboard quick actions?
# Optional sidebar grouping:
# group = "My Group"
# group_icon_svg = ''
[runtime]
isolation = "venv" # "venv" is the only value (and the default when absent)
entrypoint = "tlc_plugin_my_plugin:MyPlugin" # "pkg.module:ClassName"
requires_gpu = false # drives GPU vs CPU classification
provision_extra = "my-plugin" # your plugin's dependency group: host runs `uv sync --extra `
# The plugin's SocketIO namespace is host-derived as "/" and registered at
# startup — it is NOT declarable in the manifest (a plugin emits via ctx; the host owns
# the transport).
```
**Other keys the host reads** (all optional, read without importing the plugin):
- `[runtime]`: `auth_exempt_paths` (relative subpaths served without auth, scoped to the
plugin's own subtree), `training` (marks a training plugin), `python` / `venv_python`
(pin the interpreter the plugin's venv is built with).
- `[ui]`: `min_input_count` (minimum selected resources an `action` plugin needs — defaults to
`len(input_types)`; set `0` explicitly to require none), `action_param_names` (query params
passed through from the action launch), `quick_action_label` / `quick_action_description`
(dashboard quick-action copy).
`runtime.provision_extra` names the **optional-dependency group** the host installs into your
plugin's venv (`uv sync --extra `, or folded into the pip spec for a distribution
install). Keeping a plugin's dependencies behind an extra rather than in the base does two things:
a bare install of the distribution stays light — enough to *discover* the plugin without pulling
its whole stack — and one distribution can carry several plugins, each selecting its own extra.
For first-party plugins each value is a per-plugin extra in the `3lc-compute-plugins` umbrella
`pyproject.toml`. It is optional in the sense that a plugin needing nothing beyond the SDK (which
brings `tlc`) may omit it and still gets its own managed venv with just the base dependencies — but any
plugin sharing an umbrella declares one, since that is how its own dependencies are selected.
Every plugin runs in its own uv-managed venv, behind a worker the host spawns and talks to
over a Unix socket — the host registers the plugin from its manifest alone and never imports
its code. Isolation is venv-only: there is no in-process/host mode, and the venv is always one
the host builds and owns (never a `.venv` beside your source). `requires_gpu` is the manifest's **only placement knob**: `true` routes the job
through the shared GPU queue (one GPU job at a time, across every plugin); `false` jobs run
on the CPU queue. Both are host-owned; the plugin never picks a queue or names a lane.
### 3. Implement the plugin object
A plugin is a **subclass of `ComputePlugin`** (imported from `tlc_plugin_sdk`) —
there is no `register()` call. You must implement the one abstract method,
`get_ui_fragment()`; `id` is hydrated onto the instance from the manifest by the host.
Everything else — `compute()`, custom routes, jobs, lifecycle hooks — ships as a safe
default on the base, so you override only what you need and the host calls every hook
directly. (`compute()`'s default returns an error dict; implement it only if you expose a
synchronous `GET /compute` endpoint.)
```python
"""My Plugin — does something useful with tables."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from tlc_plugin_sdk import ComputePlugin
class MyPlugin(ComputePlugin):
"""Example plugin that analyzes a table.
Behavior only — all metadata lives in plugin.toml. The host instantiates this
via the manifest's runtime.entrypoint and stamps id/name/icon/version onto the
instance; the class does not declare them.
"""
_ui_cache: str | None = None
def get_ui_fragment(self) -> str:
"""Return the self-contained UI HTML."""
if self._ui_cache is None:
ui_path = Path(__file__).resolve().parent / "ui.html"
self._ui_cache = ui_path.read_text(encoding="utf-8")
return self._ui_cache
def compute(self, params: dict[str, Any]) -> dict[str, Any]:
"""Handle GET /api/plugins/my-plugin/compute requests."""
url = params.get("url", "")
if not url:
return {"error": "No table URL provided."}
# Do your computation here (using tlc SDK, numpy, etc.)
import tlc
table = tlc.Table.from_url(url)
return {
"row_count": table.row_count,
"columns": len(table.columns),
"message": f"Analyzed table with {table.row_count} rows.",
}
def get_route_handlers(self) -> list[Any]:
"""Return custom relative Litestar route handlers (optional)."""
return [] # Or, typically: `from . import routes; return routes.get_route_handlers()`
```
### 4. Create the UI fragment
The UI fragment is a self-contained `
My Plugin
Analyzing...
```
### 5. Discovery
There is **nothing to register**. On startup, the host scans the plugin directories
for manifests (no imports), builds a card from each, and gates compatibility against the
service version. When a plugin is actually needed, its **worker** imports the module named
in the manifest's `runtime.entrypoint` and instantiates the class inside the plugin's own
venv — the host never imports plugin code.
Because metadata is read without any import, a plugin whose environment is broken (or whose
manifest is invalid) still **lists** (greyed-out with a reason) instead of vanishing.
That's it. Drop the directory in place with a `plugin.toml` and it will be discovered on
startup.
---
## The PLUGIN_API Bridge
> **Typed declaration.** The full browser surface below is declared in
> `tlc_plugin_sdk/contract/plugin-api.d.ts` (ships in this wheel; lands at
> `/tlc_plugin_sdk/contract/plugin-api.d.ts`). A plain-JS `ui.html` can opt
> into editor type-checking without a build step:
>
> ```javascript
> ///
> var API = window.PLUGIN_API; // now typed
> ```
>
> That file declares the browser-side contract — versioned by the single
> `SDK_CONTRACT_VERSION` (see "Version & Compatibility" below). The 3LC Hub frontend
> **implements** `PLUGIN_API` when it mounts a fragment; `window.PluginJobs` **ships from this
> package** (auto-injected by the host, layered on top of the bridge, not part of it).
### How a fragment reaches the browser
The frontend is a thin Flask + Jinja2 *shell* that renders page skeletons and does **all**
data fetching client-side — it holds zero plugin knowledge and never proxies plugin data.
The mount lifecycle:
```
Browser (3LC Hub frontend, vanilla JS) Compute service (:5020)
│ user opens /plugin/{id} (Flask route → plugin_host.html)
├─ TlcPlugins.mountPlugin(id, el, ctx) ───────▶ GET /api/plugins/{id}/ui → HTML fragment
│ 1. innerHTML = fragment
│ 2. window.PLUGIN_API = {…} (the bridge, built in mountPlugin)
│ 3. re-exec the fragment's