- table get/update: 改用真实路由 /api/base/:baseId/table/:tableId(/name)
- view get/update/delete: table 域路由;view update 实现文档承诺的
--filter/--sort/--group/--column-meta(sort 自动包装 sortObjs)
- record create --fields 自动包装 {records:[{fields}]}
- record delete 单条走单删端点(修复单值 query 解析成 string 的 400),
支持位置参数;record list 加为 record get 别名
- record get-one / record get --record-id 走 table 域路由
- 移除 sql 命令:后端无 SQL 查询 API(404),技能文档 5 处同步改写
- plugin 技能 dev-workflow 内联 known-good webpack.config
(禁止 output.library.type=iife,评估轨迹中 with_skill 组也反复踩坑)
8.2 KiB
Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React component plugin. Each plugin is a standalone webpack project that outputs an IIFE bundle.
Output contract
Every plugin task ends with:
- A built IIFE bundle (
dist/main.js+ optionaldist/styles.css) - A
plugin.jsonmanifest - Uploaded to the host via
POST /api/plugin/:id/upload-bundle - Enabled via
PUT /api/base/:baseId/plugin-config/:pluginId - Installed to the target position (the install API varies by position)
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:
cd /tmp
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
cd my-plugin
pnpm install
This generates:
my-plugin/
├── src/
│ └── index.tsx # entry: calls definePlugin()
├── plugin.json # manifest
├── webpack.config.js # pre-configured externals
├── tsconfig.json
└── package.json
If the CLI doesn't work for some reason, manually create the structure by
copying plugins/batch-edit-pro/ as a template.
Step 3 — Write the plugin code
Edit src/index.tsx. The shape every plugin follows:
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.createElementor JSX (webpack handles transpilation). The host's React instance is the single source of truth. - Loading / error / empty states are required. Use
useStateto 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
{
"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"
}
idmust be unique, lowercase-kebab-case.iconis a lucide-react icon name (optional).positionsmust include the position you register inonLoad.
Step 4 — Build
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
Or if the plugin has a build script:
pnpm build
# or: npm run build
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):
// webpack.config.js — known-good (from plugins/batch-edit-pro/)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production', // default single-entry build emits an IIFE (!function / (()=>))
entry: './src/index.tsx',
output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', clean: true },
// 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'] },
],
},
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
};
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) + dist/styles.css (if styles exist).
Verify the output:
ls -la dist/main.js dist/styles.css plugin.json
Step 5 — Upload the bundle
The upload URL includes the plugin's id from plugin.json. Extract it first,
then upload.
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
if [ -z "$SESSION" ]; then
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
| grep auth_session | awk '{print $NF}')
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
fi
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -b "auth_session=$SESSION" \
-F "js=@dist/main.js" \
-F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
echo "PLUGIN_ID=$PLUGIN_ID"
- The plugin
idin the URL must match theidinplugin.json. On first upload, the plugin is created; on subsequent uploads, the existing plugin is updated. - Auth supports both session cookies (
auth_session) and Personal Access Tokens (Authorization: Bearer <PAT>). - Returns 201 on success.
Step 6 — Enable (REQUIRED before install)
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:
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
Use browser-act to verify or ask the user to check.
Pre-deliver checklist
Before claiming done:
dist/main.jsbuilt and is a valid IIFE bundleplugin.jsonat project root with correctid,entry,positions- No hardcoded URLs/table IDs/tokens — all via host SDK
- Loading, error, and empty states handled in the UI
definePlugin()called withonLoad(ctx)that registers at least one component- Upload returns 201, pluginId captured
- Enable (plugin-config) called before install
- Install returns 2xx
- Plugin renders and works
If any box is unchecked, fix it.