# 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