# Plugin development workflow The standard recipe for creating, building, and deploying a dynamic React component plugin. Each plugin is a standalone webpack (or vite) project that outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy chunks, source maps, and multiple CSS files upload alongside the entry. ## Output contract Every plugin task ends with: 1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` + `*.js.map` source maps + optional `styles.css`) 2. A `plugin.json` manifest 3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy` which handles all assets) 4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId` 5. Installed to the target position (the install API varies by position) ## Dev loop (hot reload — preferred while iterating) ```bash cd plugins/my-plugin # or wherever the project lives TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT= pnpm dev # or: node ../../packages/plugin-cli/dist/index.js dev -d . ``` This runs `webpack --watch` (production mode — dev-mode output carries `/******/` banners that fail the IIFE validator), auto-uploads on every change with a `devMode: true, buildId: ` manifest, and the open host page remounts the plugin within ~4s WITHOUT a page refresh. Production deploys (no devMode in manifest) never poll. Verified 2026-10-13 on plugin-showcase: edit → ~5s live update, window markers survive (SPA-level remount, not a reload). ## Step 1 — Understand the request Clarify what the user wants, then pick the position using `overview.md`. State the choice: "This is a ContextMenu plugin because..." ## Step 2 — Scaffold the plugin project The `plugin-cli` lives in the repo at `packages/plugin-cli/` (not yet published to npm). Run it directly: ```bash cd /tmp node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin # or the vite template: node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite cd my-plugin pnpm install ``` The scaffold adapts automatically: inside the monorepo it wires `@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a loose-typed local host module (the npm package is not published). Vite scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild` (pnpm 11 fails `pnpm install` without it). This generates: ``` my-plugin/ ├── src/ │ ├── host.ts # typed host accessor │ └── index.tsx # entry: definePlugin via host.PluginSDK ├── plugin.json # manifest ├── webpack.config.js # or vite.config.ts ├── tsconfig.json └── package.json # dev/build/deploy scripts ``` Reference implementations in the repo: `plugins/plugin-showcase/` (chunks + sourcemap + Tailwind + HashRouter route app — the v2 showcase) and `plugins/batch-edit-pro/` (classic single-file contextMenu plugin). ## Step 3 — Write the plugin code Edit `src/index.tsx`. The shape every plugin follows: ```typescript const React = window.__TEABLE_HOST__.React; const host = window.__TEABLE_HOST__; const { useFields, useTableId, useViewId } = host.SDK; const { Button } = host.UILib; const { getRecords } = host.OpenAPI; const { definePlugin, useSelection, getSelectionRecords } = host.PluginSDK; function MyComponent() { const tableId = useTableId(); const fields = useFields(); // ... business logic return React.createElement('div', null, 'Hello from plugin!'); } definePlugin({ id: 'my-plugin', name: 'My Plugin', version: '1.0.0', minAppVersion: '1.0.0', onLoad(ctx) { ctx.addContextMenuAction('myAction', MyComponent); // or: ctx.addPanel('myPanel', MyComponent); // or: ctx.addDashboardWidget('myWidget', MyComponent); // or: ctx.addView('myView', MyComponent); // or: ctx.addRoute('/my-page', MyComponent); }, onUnload(ctx) { // Optional cleanup; ctx auto-disposes registered resources }, }); ``` ### Rules - **Never hardcode** table IDs, view IDs, or tokens — use `useTableId()`, `useViewId()`, and the event bus for contextual data. - **Use `React.createElement`** or JSX (webpack handles transpilation). The host's React instance is the single source of truth. - **Loading / error / empty states** are required. Use `useState` to manage async data loading and surface errors in the UI. - For contextMenu plugins that use `getSelectionRecords`, handle the case where nothing is selected (it returns an empty array). - Plug in to events for reactivity: `ctx.on('selection.changed', ...)`, `ctx.on('record.updated', ...)`. ### Editable `plugin.json` ```json { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "minAppVersion": "1.0.0", "entry": "main.js", "style": "styles.css", "positions": ["contextMenu"], "permissions": ["data.read", "data.write"], "author": "developer@example.com", "icon": "Pencil" } ``` - `id` must be unique, lowercase-kebab-case. - `icon` is a lucide-react icon name (optional). - `positions` must include the position you register in `onLoad`. ## Step 4 — Build ```bash pnpm build # = tsc --noEmit && webpack --mode production (scaffold script) ``` **Rebuilding without the scaffold — use this exact config** (agents hand-writing webpack configs keep hitting `Error: Unsupported library type iife` — there is NO `output.library` in a working plugin config; a default single-entry production build is already IIFE, which is what the upload validator requires): ```js // webpack.config.js — known-good v2 (from plugins/plugin-showcase/) const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const pluginId = require('./plugin.json').id; module.exports = { mode: 'production', entry: './src/index.tsx', output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', // FLAT chunk names: multipart transport strips directory parts, so // nested chunk paths would 404 at runtime. chunkFilename: '[name]-[contenthash].chunk.js', // required so React.lazy/import() chunks resolve from the host runtime publicPath: `/api/plugin/${pluginId}/runtime/`, clean: true, // terser's minified "(()=>{" fails the validator's "(() =>" check environment: { arrowFunction: false }, }, devtool: 'source-map', // maps upload + serve as application/json // NO output.library — never add library.type: 'iife' (webpack rejects it) externals: { 'react': 'window.__TEABLE_HOST__.React', 'react-dom': 'window.__TEABLE_HOST__.ReactDOM', }, resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] }, module: { rules: [ { test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] }, ], }, optimization: { minimizer: [ // license-comment extraction prepends a "/*!..." banner that breaks // the IIFE-prefix check — keep comments inline new TerserPlugin({ extractComments: false }), ], }, plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })], }; ``` `react/jsx-runtime` is intentionally NOT external: the jsx automatic transform bundles it and that is safe (element factories only — hooks still come from the host singleton). Vite template notes: `build.lib formats: ['iife']` + rollup externals with `globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts `(function(...)` or `var =(function...` — both pass the validator. IIFE cannot code-split (dynamic imports inline) — use the webpack template if you need lazy chunks. If you build outside the monorepo, drop the `resolve.alias` block (it exists only so TS can type-check `import type` from `@teable/*` sources) and use `import type` + `as unknown as` casts instead of runtime imports. Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) + `dist/*.js.map` + `dist/styles.css`. ## Step 5 — Upload the bundle Preferred (handles entry + chunks + maps + css automatically): ```bash TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT= \ node ../../packages/plugin-cli/dist/index.js deploy -d . # or from the plugin dir: pnpm deploy ``` Raw curl equivalent (single-file legacy form still works): ```bash PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") curl -s -H "Authorization: Bearer $TEABLE_PAT" \ -F "js=@dist/main.js" \ -F "css=@dist/styles.css" \ -F "manifest=@plugin.json" \ "http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle" ``` Multipart notes (v2, 2026-10): - `js` field accepts up to 20 files (entry first or matched by manifest entry filename), `css` up to 5, `map` up to 20. - The plugin row must already exist (`POST /api/plugin {"id": ""}` — custom ids require the plugin_developer whitelist; the endpoint is `@TokenAccess()` so a PAT works). - Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is WIPED before writing — stale chunks don't linger, but hand-patched files on disk are discarded on the next upload. - Auth supports session cookies (`auth_session`) and PATs (`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are session-only (PAT gets 403 by design). ## Step 6 — Enable (REQUIRED before install) ```bash curl -s -b "auth_session=$SESSION" -X PUT \ "http://localhost:3003/api/base/bseAi6Hazmw7jPWZbfO/plugin-config/$PLUGIN_ID" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"tableIds":[]}' ``` `tableIds:[]` = base-wide; `tableIds:["tblX"]` = per-table. Use base-wide unless the user wants to restrict to specific tables. ## Step 7 — Install to the position The install API depends on position. See `deployment.md` for the exact call. Example — contextMenu on the test table: ```bash curl -s -b "auth_session=$SESSION" -X POST \ "http://localhost:3003/api/table/tbldtYd4ZhvkcyAaada/plugin-context-menu/install" \ -H 'Content-Type: application/json' \ -d "{\"pluginId\":\"$PLUGIN_ID\",\"name\":\"My Plugin\"}" ``` ## Step 8 — Verify in the browser Open the relevant page and confirm the plugin renders and works: - ContextMenu: right-click a record in grid view → "Plugins" group - Panel: bottom-right Plugins button in a table view - View: view-type dropdown - Dashboard: space dashboard page - Route (full-page app): `/base//p/` — install at position `route`, the page renders the plugin full-screen inside BaseLayout (bring your own router; HashRouter recommended so Next.js routing is untouched). Verified flow: enable via plugin-config → install via `POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL. Use `browser-act` to verify or ask the user to check. ## Pre-deliver checklist Before claiming done: - [ ] `dist/main.js` built and is a valid IIFE bundle (`!function` / `(function` / `(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`) - [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`) - [ ] `plugin.json` at project root with correct `id`, `entry`, `positions` - [ ] No hardcoded URLs/table IDs/tokens — all via host SDK - [ ] Loading, error, and empty states handled in the UI - [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component - [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist) - [ ] Enable (plugin-config) called before install - [ ] Install returns 2xx - [ ] Plugin renders and works - [ ] Dev-loop deploys redone as a production upload (no `devMode` in the final manifest) before handover If any box is unchecked, fix it.