@zhama/tego-plugin-sdk 0.0.0-stage → 1.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +172 -0
  2. package/README.md +846 -2
  3. package/access-catalog.schema.json +304 -0
  4. package/access-fixtures/resolve.json +1359 -0
  5. package/bin/tego-plugin-pack.mjs +2 -0
  6. package/bin/tego-plugin.mjs +3 -0
  7. package/dist/access-drizzle.cjs +1 -0
  8. package/dist/access-drizzle.d.cts +726 -0
  9. package/dist/access-drizzle.d.ts +726 -0
  10. package/dist/access-drizzle.js +1 -0
  11. package/dist/access-hono.cjs +3 -0
  12. package/dist/access-hono.d.cts +108 -0
  13. package/dist/access-hono.d.ts +108 -0
  14. package/dist/access-hono.js +3 -0
  15. package/dist/access-implications.cjs +1 -0
  16. package/dist/access-implications.d.cts +25 -0
  17. package/dist/access-implications.d.ts +25 -0
  18. package/dist/access-implications.js +1 -0
  19. package/dist/access.cjs +26 -0
  20. package/dist/access.d.cts +884 -0
  21. package/dist/access.d.ts +884 -0
  22. package/dist/access.js +26 -0
  23. package/dist/catalog-CSSbrfEf.d.cts +361 -0
  24. package/dist/catalog-CSSbrfEf.d.ts +361 -0
  25. package/dist/chunk-3KUOBGJN.js +19 -0
  26. package/dist/chunk-3WA3M347.cjs +1 -0
  27. package/dist/chunk-5CU33JVY.js +1 -0
  28. package/dist/chunk-7K37PVGV.js +5 -0
  29. package/dist/chunk-7XHF3YSK.js +1 -0
  30. package/dist/chunk-AJY7ZHXK.cjs +3 -0
  31. package/dist/chunk-BN6TGHSB.cjs +1 -0
  32. package/dist/chunk-BTHXQWHT.js +1 -0
  33. package/dist/chunk-C2OCOPZQ.cjs +1 -0
  34. package/dist/chunk-IMAVYQ4U.cjs +4 -0
  35. package/dist/chunk-OP64Y4IN.cjs +1 -0
  36. package/dist/chunk-QKSPPMVN.js +1 -0
  37. package/dist/chunk-ROFUQLOZ.cjs +1 -0
  38. package/dist/chunk-SNUCO2MK.js +1 -0
  39. package/dist/chunk-THMWRUAT.cjs +19 -0
  40. package/dist/chunk-VMCGDDGP.js +3 -0
  41. package/dist/chunk-W7C357A6.cjs +5 -0
  42. package/dist/chunk-WLTASJF6.js +4 -0
  43. package/dist/egress.cjs +7 -0
  44. package/dist/egress.d.cts +9 -0
  45. package/dist/egress.d.ts +9 -0
  46. package/dist/egress.js +7 -0
  47. package/dist/frontend.cjs +2 -0
  48. package/dist/frontend.d.cts +353 -0
  49. package/dist/frontend.d.ts +353 -0
  50. package/dist/frontend.js +2 -0
  51. package/dist/gateway-signature.cjs +1 -0
  52. package/dist/gateway-signature.d.cts +112 -0
  53. package/dist/gateway-signature.d.ts +112 -0
  54. package/dist/gateway-signature.js +1 -0
  55. package/dist/index.cjs +1 -0
  56. package/dist/index.d.cts +528 -0
  57. package/dist/index.d.ts +528 -0
  58. package/dist/index.js +1 -0
  59. package/dist/manifest-types-DQARCQJt.d.cts +627 -0
  60. package/dist/manifest-types-DQARCQJt.d.ts +627 -0
  61. package/dist/manifest.cjs +1 -0
  62. package/dist/manifest.d.cts +2824 -0
  63. package/dist/manifest.d.ts +2824 -0
  64. package/dist/manifest.js +1 -0
  65. package/dist/mcp-identity.cjs +2 -0
  66. package/dist/mcp-identity.d.cts +106 -0
  67. package/dist/mcp-identity.d.ts +106 -0
  68. package/dist/mcp-identity.js +2 -0
  69. package/dist/pack.cjs +1 -0
  70. package/dist/pack.d.cts +169 -0
  71. package/dist/pack.d.ts +169 -0
  72. package/dist/pack.js +1 -0
  73. package/dist/publish.cjs +1 -0
  74. package/dist/publish.d.cts +96 -0
  75. package/dist/publish.d.ts +96 -0
  76. package/dist/publish.js +1 -0
  77. package/dist/scaffold.cjs +523 -0
  78. package/dist/scaffold.d.cts +182 -0
  79. package/dist/scaffold.d.ts +182 -0
  80. package/dist/scaffold.js +523 -0
  81. package/dist/server-runtime.cjs +1 -0
  82. package/dist/server-runtime.d.cts +63 -0
  83. package/dist/server-runtime.d.ts +63 -0
  84. package/dist/server-runtime.js +1 -0
  85. package/dist/server.cjs +1 -0
  86. package/dist/server.d.cts +1808 -0
  87. package/dist/server.d.ts +1808 -0
  88. package/dist/server.js +1 -0
  89. package/dist/testing.cjs +2 -0
  90. package/dist/testing.d.cts +92 -0
  91. package/dist/testing.d.ts +92 -0
  92. package/dist/testing.js +2 -0
  93. package/dist/vite.cjs +1 -0
  94. package/dist/vite.d.cts +133 -0
  95. package/dist/vite.d.ts +133 -0
  96. package/dist/vite.js +1 -0
  97. package/package.json +160 -4
  98. package/sql/access.sql +73 -0
package/README.md CHANGED
@@ -1,3 +1,847 @@
1
- # Temporary Holding Version
1
+ # @tego/plugin-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Everything needed to build, package and test a Tego plugin.
4
+
5
+ Before this existed the only reference was `plugins/tego-crm`'s source, which
6
+ meant every new plugin inherited that one's accidental decisions and each
7
+ correction cost twice as much as the last. Read this instead; open CRM only for
8
+ an example of a screen.
9
+
10
+ - [What a plugin is](#what-a-plugin-is)
11
+ - [Which part does what](#which-part-does-what)
12
+ - [Package layout](#package-layout)
13
+ - [The manifest](#the-manifest)
14
+ - [`hostApiRange`](#hostapirange)
15
+ - [Configuration](#configuration)
16
+ - [Building and packaging](#building-and-packaging)
17
+ - [The local loop](#the-local-loop)
18
+ - [Testing](#testing)
19
+ - [Developing outside this repository](#developing-outside-this-repository)
20
+ - [Upload, install, run](#upload-install-run)
21
+
22
+ ## What a plugin is
23
+
24
+ One archive with up to three parts, described by a single `tego.plugin.json` at
25
+ its root. At least one part is required.
26
+
27
+ | Part | Runs | Written against |
28
+ | ---------- | ----------------------------------- | ------------------------------------ |
29
+ | `frontend` | In the shell, as a SystemJS module | `@tego/plugin-sdk/frontend` |
30
+ | `backend` | As a container the platform manages | `@tego/plugin-sdk/server-runtime` |
31
+ | `claw` | Inside an avatar's claw container | OpenClaw's own plugin SDK, unchanged |
32
+
33
+ Start one with the scaffold rather than by copying:
34
+
35
+ ```bash
36
+ pnpm plugin:new
37
+ ```
38
+
39
+ It asks for an id, display names, a scope and which parts you want, then writes
40
+ a package that passes upload validation as-is — manifest, ten locale bundles,
41
+ and one working example per part.
42
+
43
+ ## Which part does what
44
+
45
+ **The frontend part opens a full working page.** A record list, a detail view,
46
+ a dashboard: things a person navigates to from the sidebar and works in. It
47
+ owns every route under `/p/<pluginId>`.
48
+
49
+ The sidebar gives you **one row**, named by your `displayName`, sitting in the
50
+ 扩展 flyout. It opens `/p/<pluginId>` and that is the last say the shell has:
51
+ which page a user lands on, and how they move between your pages, is yours to
52
+ render — a tab bar, a nav rail, whatever the work needs. The shell cannot
53
+ publish your pages as rows of its own, because how many there are is decided by
54
+ whoever installs plugins, and three plugins of four pages each would push the
55
+ built-in sections off the screen.
56
+
57
+ **Anything inside a conversation goes through the claw part**, driven by the
58
+ avatar: a skill tells the agent when to act, and the agent draws cards with the
59
+ platform's a2ui tools. Do not try to render into the chat from the frontend
60
+ part — there is no seam for it, and the one that exists is claw's.
61
+
62
+ **The backend part is the plugin's own service and its own data.** It gets a
63
+ dedicated Postgres schema, a role scoped to it, and object storage under its own
64
+ prefix. It is reachable only through the platform: the gateway authenticates the
65
+ caller, checks the tenant and the permission, and forwards. No published port,
66
+ no ingress route.
67
+
68
+ A plugin does not need all three. A claw-only plugin is a normal thing to ship.
69
+
70
+ ### The identity your backend receives is verified for you
71
+
72
+ Your `fetch(request, ctx, host)` is called with a `ctx` naming the user, their
73
+ tenant, their roles and — when a claw container is calling — the avatar. You can
74
+ authorize against all of it. `serveBackend` has already checked that the
75
+ platform is what wrote it: the gateway signs the identity per request with a key
76
+ derived for your install, and anything that does not verify gets a 401 without
77
+ reaching your code.
78
+
79
+ So do not accept identity from anywhere else. A `userId` in a query parameter,
80
+ a body field or a header of your own is a caller-supplied string, and the whole
81
+ point of `ctx` is that it is not. There is also no token to parse — the caller's
82
+ credentials stop at the platform, which is what keeps a plugin from being a
83
+ place the user's entire account can be driven from.
84
+
85
+ ### Your frontend needs its own error boundary
86
+
87
+ The shell mounts you into a plain DOM node and wraps that node in a boundary,
88
+ so a throw from `mount` itself, or from the shell's own work around it, shows
89
+ an error page with the sidebar intact rather than taking the app down.
90
+
91
+ That boundary cannot see inside you. You render into your own React root, and
92
+ React contains a root's render errors within that root — the shell's boundary
93
+ is never on the path. Without one of your own, a component that throws on the
94
+ third tab leaves a blank rectangle and a console entry, which reads as the
95
+ platform having lost the page.
96
+
97
+ ## Package layout
98
+
99
+ ```plaintext
100
+ my-plugin/
101
+ ├── tego.plugin.json # the manifest — the only required file
102
+ ├── frontend/
103
+ │ ├── index.js # SystemJS bundle, built by the SDK's vite preset
104
+ │ └── i18n/<locale>.json # all ten locales, see below
105
+ ├── backend/
106
+ │ ├── index.js # one call to serveBackend()
107
+ │ └── migrations/*.sql # append-only, applied by the host
108
+ └── claw/
109
+ ├── openclaw.plugin.json # an intact OpenClaw manifest
110
+ ├── index.js
111
+ └── skills/
112
+ ```
113
+
114
+ Every path above is a default you can override in the manifest, but there is
115
+ rarely a reason to. `tego-plugin-pack` produces exactly this from your sources.
116
+
117
+ The ten locales are not negotiable for a part that ships UI. The platform
118
+ serves `zh-CN zh-TW en ja ko de es fr it pt`; shipping two of them means a
119
+ Japanese operator opens your plugin and finds the one screen in the product
120
+ that is untranslated, which reads as the platform being half-finished. Upload
121
+ refuses an incomplete set, where you can still act on it — a runtime fallback
122
+ would hide the gap forever.
123
+
124
+ ## The manifest
125
+
126
+ ```jsonc
127
+ {
128
+ "manifestVersion": 1,
129
+ "id": "acme-crm",
130
+ "version": "1.0.0",
131
+ "displayName": { "zh-CN": "客户关系", "en": "CRM" },
132
+ "scope": "avatar",
133
+ "hostApiRange": "^1.3.0",
134
+ "declares": { "database": true, "storage": true, "events": true },
135
+ "parts": {
136
+ "frontend": {
137
+ "icon": "handshake",
138
+ "nav": [
139
+ {
140
+ "key": "companies",
141
+ "labelKey": "nav.companies",
142
+ "icon": "building",
143
+ "path": "/companies",
144
+ },
145
+ ],
146
+ },
147
+ "backend": {},
148
+ "claw": {},
149
+ },
150
+ }
151
+ ```
152
+
153
+ **`scope`** decides who the plugin belongs to, and it is declared rather than
154
+ inferred.
155
+
156
+ - `avatar` — a capability of a digital avatar. Install picks which avatars get
157
+ it; its pages appear while one of those is the active avatar. A plugin with a
158
+ claw part is necessarily this, since a claw extension loads inside one
159
+ avatar's container.
160
+ - `tenant` — a workspace application with no per-avatar meaning, open to
161
+ everyone in the tenant.
162
+
163
+ **`declares`** is what the install screen tells the operator: a dedicated
164
+ schema, object storage, `plugin.<id>.*` events. These are **disclosures, not
165
+ grants**. Plugins are first-party and the frontend runs with the shell's own
166
+ privileges, so calling them permissions would promise an isolation boundary
167
+ that is not there. `database` is the one that also changes behaviour, because
168
+ provisioning a schema is something the host does or does not do.
169
+
170
+ **`nav` declares your pages, not sidebar rows.** The sidebar shows your plugin
171
+ as one row that opens it at its root, and you route between your pages
172
+ yourself. Leave `nav` empty and your frontend gets neither a row nor a page.
173
+
174
+ **Access follows the avatars, not roles.** The host writes no role menu or
175
+ permission for a plugin. An avatar-scoped plugin may be used by whoever may use
176
+ an avatar it is installed on — an administrator, the avatar's owner, or someone
177
+ granted `use` or above; view-only access does not count. A tenant-scoped plugin
178
+ is open to the whole tenant. Access is all-or-nothing, so `ctx.can(key)` only
179
+ says whether `key` is one of your nav entries; per-page and per-record rules
180
+ belong in your access catalog (`@tego/plugin-sdk/access`).
181
+
182
+ **`frontend.icon` is your sidebar icon**; the per-entry `icon` and `order` in
183
+ `nav` are accepted for compatibility and not read. Icons come from a closed
184
+ list (`PLUGIN_NAV_ICONS`): an arbitrary `lucide-react` name can only be
185
+ resolved dynamically, which pulls the whole icon set into the shell's eager
186
+ bundle to decorate rows most tenants do not have. Declare nothing and you get
187
+ the generic plugin glyph.
188
+
189
+ ### Digital employee templates and work delegation
190
+
191
+ An avatar plugin may ship template assets in its own package:
192
+
193
+ ```jsonc
194
+ "agentTemplates": [{
195
+ "key": "safety-employee",
196
+ "entry": "templates/safety-employee/template.json",
197
+ "icon": "templates/safety-employee/avatar.png"
198
+ }]
199
+ ```
200
+
201
+ Author the template as a directory beside its entry: `template.config.json`
202
+ (everything except workspace documents), `workspace/*.md` for the main agent's
203
+ root files, other files under `workspace/` shipped at their relative path, and
204
+ — when the config lists `agents`, main first — one `experts/<id>/` per expert.
205
+ `tego-plugin-pack` rebuilds `template.json` from these before packing (commit
206
+ the result); `tego-plugin-pack templates` rebuilds without packing. An entry
207
+ with no `template.config.json` beside it is shipped as written.
208
+
209
+ `tego-plugin-pack` copies only the declared files. Upload validates the template
210
+ against the platform template schema. Approving a release publishes its
211
+ templates for that tenant; retiring, revoking or archiving the last approved
212
+ release hides them, and the tenant's catalog lists them only while the plugin
213
+ is installed and enabled. The template row belongs to the release: admins can
214
+ change its price, nothing else. The host adds the owning plugin to
215
+ `strictRequiredPlugins`, so creation fails if it cannot bind the plugin to the
216
+ new employee. Template content and portrait remain in the plugin package.
217
+
218
+ Plugins using the host's work delegation API must declare a policy:
219
+
220
+ ```jsonc
221
+ "workDelegation": { "allowedAvatarRoles": ["sales-assistant"] }
222
+ ```
223
+
224
+ The host checks the installed release's policy when listing candidates,
225
+ choosing a workspace default, enabling delegation and resolving an existing
226
+ grant. Omission denies delegation; `"*"` permits any employee role. Revoking
227
+ an existing grant remains possible when the employee is no longer eligible.
228
+
229
+ The host owns the consent flow; plugins do not build grant or revoke buttons.
230
+ A person grants per employee in a host dialog that states what the employee
231
+ may do and the backend's declared `egress`. The dialog opens on its own the
232
+ first time the person enters the plugin with an employee they have not
233
+ granted; declining keeps the plugin usable and the host does not prompt again
234
+ for that employee. Describe the work in the dialog with optional fields:
235
+
236
+ ```jsonc
237
+ "workDelegation": {
238
+ "allowedAvatarRoles": ["sales-assistant"],
239
+ "summary": { "zh-CN": "员工会读取你可见的客户…", "en": "Reads the companies you can see…" },
240
+ "privacyPolicyUrl": "https://example.com/privacy",
241
+ "conversationCapture": true
242
+ }
243
+ ```
244
+
245
+ `summary` is at most 120 characters per locale. `privacyPolicyUrl` must be
246
+ https. `conversationCapture: true` adds an unticked option to record the
247
+ person's new conversations with the employee, for plugins that read
248
+ `host.sources`. These fields need `hostApiRange` `^1.33.0` or later.
249
+
250
+ From 1.33 the frontend reads the state from `ctx.delegation` and calls
251
+ `ctx.delegation.open()` wherever work needs the grant; the promise resolves
252
+ with the state once the dialog closes:
253
+
254
+ ```ts
255
+ const state = ctx.delegation?.current(); // status: granted | not_granted | not_ready | ineligible | no_employee | loading
256
+ if (state?.status !== 'granted') {
257
+ const next = await ctx.delegation?.open();
258
+ if (next?.status !== 'granted') return;
259
+ }
260
+ ```
261
+
262
+ `ctx.delegation` is present only when the manifest declares `workDelegation`
263
+ and `hostApiRange` starts at 1.33 or later. Plugins without a frontend need
264
+ nothing more: the usage guide entry below opens the same dialog. Delegation
265
+ is judged by your backend through the signed `delegation` on machine
266
+ requests, so the manifest is rejected when `workDelegation` has no backend part. Grants
267
+ and revocations are recorded in the governance audit with the release
268
+ version; a plugin upgrade does not ask people to grant again.
269
+
270
+ The host also lists delegation in the employee's usage guide: for every person
271
+ using an eligible employee it adds the setup item
272
+ `plugin:<pluginId>:work_delegation`, `ready` once that person has delegated.
273
+ Its button opens the host dialog. `settingsRoute` is deprecated: the host no
274
+ longer links the item to a plugin page. Template quick prompts can name the
275
+ item in `requires`. Do not report delegation from your own readiness route.
276
+
277
+ Plugins that need people to finish something before an employee can work for
278
+ them report it per person:
279
+
280
+ ```jsonc
281
+ "agentReadiness": { "path": "readiness" }
282
+ ```
283
+
284
+ `path` is a backend route relative to the plugin root, so the manifest needs a
285
+ backend part. Declaring `agentReadiness` or `workDelegation.settingsRoute`
286
+ requires `hostApiRange` `^1.30.0` or later.
287
+
288
+ The host calls the route as the viewing person, with `avatarId` in the query,
289
+ and expects `{ "items": [...] }` (optionally wrapped in `data`). Each item has
290
+ an `id` unique within the plugin, `title`, `status` (`ready`, `action_needed`,
291
+ `not_applicable` hides the item and the quick prompts requiring it, `unknown`),
292
+ `owner` (`self` or `other`), and optionally `why`, `ownerHint`, `optional` and
293
+ `action: { label, route }`. Text fields accept `LocalizedText`; the host picks
294
+ the viewer's locale, then `en`. `action.route` is a path inside your plugin.
295
+ A response that fails validation, times out, repeats an id or uses
296
+ `work_delegation` is shown as a single `unknown` item.
297
+
298
+ Plugins that bind a business record to a conversation declare their read-only
299
+ routes instead of adding entity names or paths to the host:
300
+
301
+ ```jsonc
302
+ "conversationRecordRoutes": [
303
+ { "entity": "contact", "path": "contacts", "contextQuery": "context=pipeline" }
304
+ ]
305
+ ```
306
+
307
+ The host calls the declared route with the URL-encoded record ID and the
308
+ verified user context. The plugin remains responsible for record permission
309
+ checks and the returned record and context projection.
310
+
311
+ ## `hostApiRange`
312
+
313
+ A caret range over the shared-module surface the shell hands your frontend:
314
+ React, `@tego/ui`, `react-i18next`, `lucide-react`,
315
+ `@tego/chat/plugin-surface`. The current version is exported as
316
+ `HOST_API_VERSION`.
317
+
318
+ Declare the lowest version whose features you actually use, not the newest one
319
+ available. `^1.0.0` keeps loading on every 1.x shell; `^1.3.0` says you need
320
+ `PluginHostContext.events` and will be refused by a shell that predates it. A
321
+ major bump refuses every plugin declaring an older range, which is the point —
322
+ the alternative is failing inside a hook with a message about neither.
323
+
324
+ Required once you ship a frontend or backend part. A claw-only plugin is
325
+ governed by claw's own `minHostVersion` instead.
326
+
327
+ ## Roles and permissions inside your plugin (Host API 1.22)
328
+
329
+ `nav` decides who may open your plugin; what they may do once inside is yours,
330
+ and `@tego/plugin-sdk/access` is how you answer it. Accounts and host roles stay
331
+ the host's. Your roles, their grants and who holds them live in your own schema,
332
+ and the tenant's administrators edit them in a screen you embed. The model and
333
+ the reasons behind it are in `docs/design/plugin-access.md`.
334
+
335
+ Declare a catalog, validated by `tego-plugin-pack` against
336
+ `@tego/plugin-sdk/access-catalog.schema.json`:
337
+
338
+ ```jsonc
339
+ // tego.plugin.json
340
+ { "hostApiRange": "^1.22.0", "access": { "catalog": "shared/access.json" } }
341
+
342
+ // shared/access.json
343
+ {
344
+ "version": 1,
345
+ "groups": [{ "key": "records", "labelKey": "access.group.records" }],
346
+ "scopes": { "own": { "labelKey": "access.scope.own" }, "all": { "labelKey": "access.scope.all" } },
347
+ "permissions": {
348
+ "record.view": { "group": "records", "labelKey": "access.record.view", "scopes": ["own", "all"],
349
+ "agent": { "requires": ["records.read"] } },
350
+ "record.delete": { "group": "records", "labelKey": "access.record.delete", "agent": { "allowed": false } }
351
+ },
352
+ "roles": {
353
+ "admin": { "nameKey": "access.role.admin", "grants": { "access.manage": true, "record.view": "all", "record.delete": true } },
354
+ "member": { "nameKey": "access.role.member", "grants": { "record.view": "own" } }
355
+ },
356
+ "hostRoleBindings": { "admin": ["admin"], "super_admin": ["admin"] },
357
+ "defaults": { "unassigned": "member" },
358
+ "assignment": { "principals": ["user", "unit"] }
359
+ }
360
+ ```
361
+
362
+ The build refuses a catalog that cannot work:
363
+
364
+ - a reference to an undeclared permission or scope;
365
+ - a menu key that is not in `nav`;
366
+ - `unit` principals without `directory.read`;
367
+ - a host `admin` who could not reach the access settings, which is the one
368
+ way a tenant locks itself out.
369
+
370
+ On the backend, one engine per plugin and one session per request:
371
+
372
+ ```ts
373
+ import { createAccess, accessCatalogFromJson } from '@tego/plugin-sdk/access';
374
+ import { accessHono, assertRouteCoverage } from '@tego/plugin-sdk/access/hono';
375
+
376
+ export const access = createAccess({
377
+ catalog: accessCatalogFromJson(catalogJson),
378
+ });
379
+ const { policy, routes } = accessHono({ session: c => c.env.access }); // access.forRequest(ctx, host)
380
+
381
+ app.get('/records', policy.permission('record.view'), listRecords);
382
+ app.delete(
383
+ '/records/:id',
384
+ policy.resource('record.delete', loadRecord, { conceal: true }),
385
+ deleteRecord
386
+ );
387
+ app.route('/', routes()); // /access/me, /access/roles, /access/assignments, …
388
+
389
+ // in start(host): bring the built-in roles in line with the catalog
390
+ await access.reconcile(host);
391
+ ```
392
+
393
+ - **Every route carries exactly one policy.** Test it with
394
+ `assertRouteCoverage(app, { publicRoutes })`. An unmarked route fails the
395
+ test rather than defaulting to open.
396
+ - **Lists go through the scope.** `session.filter('record.view', { alias: 'r' })`
397
+ returns the SQL condition for a list query. It comes from the same evaluator
398
+ that judges a single record, so the list and the detail page cannot disagree.
399
+ - **`authorizeStorage` goes through the same engine:**
400
+ `access.forSubject(await access.subjectFor(userId, host), host)`.
401
+ - **Background jobs never act as a user.** They are trusted system code; work
402
+ done for a person arrives through claw's `fetch`, with a delegation.
403
+ - **The tables go in your migrations.** Re-export them from
404
+ `@tego/plugin-sdk/access/drizzle` and run `db:generate`; a Python backend uses
405
+ `@tego/plugin-sdk/access.sql`. Every table carries `tenant_id`, which
406
+ `host.tenantId` supplies.
407
+
408
+ On the frontend, `@tego/plugin-access-ui` is bundled into your plugin, not
409
+ shared by the shell:
410
+
411
+ - mount `<AccessProvider api={ctx.api}>` under your own `QueryClientProvider`;
412
+ - gate the UI with `useAccess()`, `<Can>` and `filterNav`;
413
+ - embed `<AccessSettings />` in your settings;
414
+ - add the package to your Tailwind `content`.
415
+
416
+ `tego-plugin-pack` merges its strings into your ten locales under `tegoAccess`.
417
+ What the frontend hides is presentation only: the backend is the boundary.
418
+
419
+ Python backends get the same model, tables and `/access` contract from
420
+ `tego_plugin_sdk.access`, with the FastAPI adapter in
421
+ `tego_plugin_sdk.access.fastapi`: `AccessFastApi`, and `assert_route_coverage`.
422
+
423
+ ## Configuration
424
+
425
+ Three sections, declared and stored separately: `frontend`, `backend`, `claw`.
426
+ They are never merged. Which section a value is in decides who ever sees it, so
427
+ the separation is the security property rather than a filing convention.
428
+
429
+ ```mermaid
430
+ flowchart LR
431
+ fe["parts.frontend.config"] --> store["stored per install,<br/>three rows"]
432
+ be["parts.backend.config"] --> store
433
+ claw["configSchema in<br/>claw/openclaw.plugin.json"] --> store
434
+ store -->|"with discovery"| feOut["the browser"]
435
+ store -->|"host API, on startup"| beOut["the backend container"]
436
+ store -->|"merged with per-avatar overrides"| clawOut["the avatar's openclaw.json"]
437
+ ```
438
+
439
+ Declare the frontend and backend sections in `tego.plugin.json`:
440
+
441
+ ```jsonc
442
+ "backend": {
443
+ "config": {
444
+ "schema": {
445
+ "type": "object",
446
+ "properties": {
447
+ "apiBaseUrl": { "type": "string", "format": "uri" },
448
+ "apiKey": { "type": "string", "minLength": 8 }
449
+ },
450
+ "required": ["apiBaseUrl", "apiKey"],
451
+ "additionalProperties": false
452
+ },
453
+ "uiHints": {
454
+ "apiKey": {
455
+ "label": { "zh-CN": "接口密钥", "en": "API key" },
456
+ "sensitive": true
457
+ }
458
+ }
459
+ }
460
+ }
461
+ ```
462
+
463
+ **Do not declare the claw section here.** `claw/openclaw.plugin.json` already
464
+ carries a `configSchema` that claw itself validates against; a second
465
+ declaration would be a second truth about the same fields.
466
+
467
+ ### The allowed schema is narrow
468
+
469
+ A flat object of scalars, string enums and string arrays. No nesting, no
470
+ `$ref`, no unions. Two reasons, and the second is load-bearing:
471
+
472
+ - The form renders without you previewing it. Anything the renderer would fall
473
+ back to a JSON textarea for is a field the operator fills by typing JSON,
474
+ which is not a form.
475
+ - Per-avatar claw overrides merge at top-level key granularity, because
476
+ deep-merging objects and arrays has no single obvious semantics. A nested
477
+ object would be replaced wholesale by an override, which is not what its
478
+ shape suggests.
479
+
480
+ ### Secrets
481
+
482
+ Mark a field `sensitive` and it is stored encrypted, never returned in
483
+ plaintext, and never placed in the container's environment. Reads substitute
484
+ `__OPENCLAW_REDACTED__`; writing that value back means "keep what is stored", so
485
+ saving a form does not require retyping a key nobody was shown.
486
+
487
+ `sensitive` in the **frontend** section is a validation error, not a stripped
488
+ flag: everything that section contains is delivered to the browser, so a secret
489
+ there is public by construction.
490
+
491
+ ### How each part reads its own
492
+
493
+ Frontend, already resolved when the surface mounts:
494
+
495
+ ```ts
496
+ export default definePluginFrontend({
497
+ mount(el, ctx) {
498
+ const endpoint = ctx.config['apiBaseUrl'];
499
+ },
500
+ });
501
+ ```
502
+
503
+ Backend, fetched over the host API before the first request is accepted:
504
+
505
+ ```ts
506
+ const { apiKey } = host.config.current();
507
+ await host.config.refresh(); // for a rotated credential, without a restart
508
+ ```
509
+
510
+ Environment variables are deliberately not the channel. A secret in the
511
+ environment appears in `docker inspect`, in the orchestrator's API and in crash
512
+ dumps, and it is fixed at container creation — rotating a key would mean
513
+ recreating the container.
514
+
515
+ Claw reads its section from the avatar's `openclaw.json`, where the platform
516
+ projects the tenant default with that avatar's overrides applied.
517
+
518
+ ### Required and unset
519
+
520
+ A **backend** field that is required and unfilled leaves the install in
521
+ `awaiting_config`: installed, no container started, and calls answered with an
522
+ explanation instead of a 502. Filling it in starts the backend. Only the backend
523
+ section can hold anything back — a missing frontend setting is a page that
524
+ renders wrong, and a missing claw setting is claw's business.
525
+
526
+ An upgrade whose new schema rejects the stored values puts the backend back into
527
+ `awaiting_config` rather than starting it on a value it will choke on.
528
+
529
+ ## Building and packaging
530
+
531
+ ```bash
532
+ pnpm exec tego-plugin-pack # from the package root
533
+ pnpm exec tego-plugin-pack --watch # frontend rebuild loop, see below
534
+ ```
535
+
536
+ The packer owns the archive layout because that layout is the platform's
537
+ contract: the server reads `tego.plugin.json`, resolves the part defaults, and
538
+ expects the bundle, the migrations and the claw subtree at exactly those paths.
539
+ It builds the frontend with Vite through the SDK's preset, bundles the backend
540
+ and claw entries with esbuild, and writes a deterministic ZIP.
541
+
542
+ Your `vite.config.ts` is one call:
543
+
544
+ ```ts
545
+ import { tegoPluginFrontend } from '@tego/plugin-sdk/vite';
546
+
547
+ export default tegoPluginFrontend({ entry: 'src/index.tsx' });
548
+ ```
549
+
550
+ The preset is not a convenience wrapper. The shell can only load a SystemJS
551
+ module whose shared imports were rewritten to the exact ids it registered, and
552
+ both of those are one-line mistakes that build cleanly and fail at load with a
553
+ message about an unknown scheme.
554
+
555
+ ### Styling with Tailwind
556
+
557
+ A plugin that compiles its own utilities passes `tegoPluginTailwind` to
558
+ `tailwindcss` (the plugin's own dependency):
559
+
560
+ ```ts
561
+ import { resolve } from 'node:path';
562
+
563
+ import { tegoPluginFrontend, tegoPluginTailwind } from '@tego/plugin-sdk/vite';
564
+ import tailwindcss from 'tailwindcss';
565
+
566
+ const config = tegoPluginFrontend({ entry: 'frontend/src/index.tsx', pluginId: 'acme-crm' });
567
+ config.css = {
568
+ postcss: {
569
+ plugins: [
570
+ tailwindcss(
571
+ tegoPluginTailwind({
572
+ pluginId: 'acme-crm',
573
+ root: import.meta.dirname,
574
+ content: [resolve(import.meta.dirname, 'src/**/*.{ts,tsx}')],
575
+ })
576
+ ),
577
+ ],
578
+ },
579
+ };
580
+ export default config;
581
+ ```
582
+
583
+ It takes the design tokens from `@tego/ui`'s preset (the one the shell compiles
584
+ with), leaves the reset to the shell, scopes every utility under `.<pluginId>`,
585
+ and compiles the classes of `@tego/ui` and `@tego/plugin-access-ui` components
586
+ the bundle uses. Packages resolve from `root`, so the same call works in this
587
+ repo and in a standalone one. Do not load a `tailwind.config.js` by relative
588
+ path: outside this repository there is nothing at that path.
589
+
590
+ For anything the conventions cannot infer, call `packPlugin` from a script:
591
+
592
+ ```ts
593
+ import { packPlugin } from '@tego/plugin-sdk/pack';
594
+
595
+ await packPlugin({ root: process.cwd(), backend: { external: ['sharp'] } });
596
+ ```
597
+
598
+ ## The local loop
599
+
600
+ Without this, changing one line of frontend code costs a pack, an upload, an
601
+ install and a refresh.
602
+
603
+ ```bash
604
+ pnpm exec tego-plugin-pack --watch # serves the bundle on :5180
605
+ VITE_TEGO_LOCAL_PLUGINS=acme-crm=http://localhost:5180/index.js pnpm -F @tego/ai-studio dev
606
+ ```
607
+
608
+ Saving a file rebuilds and reloads the plugin page. The release still supplies
609
+ everything else — nav entries, permissions, version — only the bundle's origin
610
+ changes, and the override is compiled out of a production build entirely.
611
+
612
+ There is **no module-level HMR**. State inside the page is lost on each rebuild.
613
+ Hot replacement across the SystemJS boundary means unmounting the plugin's React
614
+ root and re-registering shared modules, which is a lot of machinery for
615
+ something that would silently desync; a one-second reload is always right.
616
+
617
+ The backend runs on the host instead of in a container:
618
+
619
+ ```bash
620
+ TEGO_PLUGIN_BACKEND_BASE_URL=http://localhost:18800 pnpm -F @tego/server dev
621
+
622
+ pnpm -F @tego/server exec tsx scripts/run-plugin-backend-locally.ts \
623
+ acme-crm "$PWD/plugins/acme-crm/build/acme-crm-1.0.0.zip"
624
+ ```
625
+
626
+ The platform then adopts the process at that address rather than starting a
627
+ container it could not reach from the host. The port is fixed at 18800, and the
628
+ zip path has to be absolute — `pnpm exec` runs with `apps/server` as its working
629
+ directory. The plugin must already be installed; the script reads its schema
630
+ name and host API token from that install.
631
+
632
+ ## Testing
633
+
634
+ The interesting code in a plugin backend is its SQL — multi-table writes in one
635
+ transaction, `FOR UPDATE SKIP LOCKED`, partial unique indexes — and none of that
636
+ means anything against a fake. `createTestPluginHost` stands up the real thing:
637
+
638
+ ```ts
639
+ import { createTestPluginHost } from '@tego/plugin-sdk/testing';
640
+
641
+ const harness = await createTestPluginHost({
642
+ pluginId: 'acme-crm',
643
+ migrationsDir: new URL('../migrations', import.meta.url).pathname,
644
+ config: { apiBaseUrl: 'https://example.test' },
645
+ });
646
+ afterAll(() => harness.close());
647
+ beforeEach(() => harness.reset());
648
+
649
+ const response = await backend.fetch(request, userContext, harness.host);
650
+ expect(harness.publishedEvents).toEqual([...]);
651
+ ```
652
+
653
+ A throwaway schema with your migrations applied, behind the same
654
+ `PluginScopedDb` the host hands a running backend — including real transactions
655
+ on one connection, which is the thing most easily got wrong: a body whose
656
+ statements land on pooled connections is not a transaction, and a double would
657
+ let that pass.
658
+
659
+ `harness.failPublish()` makes `events.publish` throw, which is the only way to
660
+ reach the case where a failed announcement undoes a committed write.
661
+
662
+ ## Developing outside this repository
663
+
664
+ Three SDK packages are published to npm under `@zhama/tego-*` and installed
665
+ under their workspace names with npm aliases, so imports and `$schema` paths
666
+ read exactly as they do inside this repository:
667
+
668
+ ```json
669
+ "devDependencies": {
670
+ "@tego/plugin-sdk": "npm:@zhama/tego-plugin-sdk@^1.35.0",
671
+ "@tego/ui": "npm:@zhama/tego-ui@^1.35.0",
672
+ "@tego/plugin-access-ui": "npm:@zhama/tego-plugin-access-ui@^1.35.0"
673
+ }
674
+ ```
675
+
676
+ `@tego/plugin-access-ui` is only needed by a plugin that shows access
677
+ settings. The TypeScript presets and the lint config are not packages: the
678
+ scaffold writes them into the plugin (`tsconfig.base.json`,
679
+ `tsconfig.frontend.json`, `tsconfig.backend.json`, `eslint.config.js`). They
680
+ drive the editor, `tsgo` and ESLint only — the build is the SDK's — so change
681
+ them as you like.
682
+
683
+ The alias is required, not cosmetic: `@tego/ui` is a shared module id the shell
684
+ resolves your imports against, and the `@tego` scope on the public registry
685
+ belongs to another project. Never install a plain `@tego/*` package from npm.
686
+
687
+ Start from the scaffold instead of by hand. The SDK ships it as
688
+ `tego-plugin create`, so no checkout of this repository is needed: it writes
689
+ all of the above, `.prettierrc.json`, `.gitignore` and a `pnpm-workspace.yaml`
690
+ that allows esbuild's install script, into `<dir>/<id>/`. The answers come as
691
+ JSON on stdin (`scope` is `avatar` or `tenant`, default `avatar`), and
692
+ `hostApiRange` follows the SDK you run:
693
+
694
+ ```bash
695
+ echo '{"id":"acme-notes","displayNameZh":"笔记","displayNameEn":"Notes",
696
+ "descriptionZh":"…","descriptionEn":"…","parts":["frontend","backend"]}' \
697
+ | npx -p @zhama/tego-plugin-sdk tego-plugin create --dir ~/code
698
+ cd ~/code/acme-notes && pnpm install
699
+ ```
700
+
701
+ Inside this repository `pnpm plugin:scaffold --standalone ~/code` does the
702
+ same, and `pnpm plugin:new` writes the in-repo (`workspace:*`) variant; all
703
+ three use `@tego/plugin-sdk/scaffold`. Tego Studio creates plugins with
704
+ `tego-plugin create` too.
705
+
706
+ **Versions.** Every package is released at one version whose major.minor is
707
+ `HOST_API_VERSION`. A manifest declaring `"hostApiRange": "^1.35.0"` installs
708
+ `^1.35.0` of the SDK; raise both together when you start using a newer host
709
+ API.
710
+
711
+ **The chat surface.** `@tego/chat/plugin-surface` (an agent conversation
712
+ embedded in a plugin page) is available only to plugins built inside the Tego
713
+ repository. It is not published and the SDK carries no types for it.
714
+
715
+ **License.** The published packages are under the Tego Plugin SDK License
716
+ (`LICENSE` in each package): free to use for building, testing and
717
+ distributing plugins that run in Tego, not for other products, and not
718
+ redistributable on their own. Your plugin's own code stays yours.
719
+
720
+ **Toolchain.** Inside the repo the root `package.json` provides `tsgo`,
721
+ `eslint`, `prettier` and `typescript`. A standalone repository installs them
722
+ itself; the scaffold pins the same versions.
723
+
724
+ Releasing these packages is `pnpm sdk:publish` from the repo root (see
725
+ `scripts/sdk-release/`); `node scripts/sdk-release/smoke.mjs` proves a
726
+ standalone plugin installs, checks, tests and packs against the staged
727
+ tarballs with both npm and pnpm.
728
+
729
+ ## Upload, install, run
730
+
731
+ 1. **Pack.** `pnpm exec tego-plugin-pack` writes `build/<id>-<version>.zip`.
732
+ 2. **Upload**, in the admin plugin screen. Everything that can be checked is
733
+ checked here, where you can still fix it: the manifest, the schema subset,
734
+ the ten locales, the icon names, the nav key lengths. An uploaded release is
735
+ a `draft` until approved.
736
+ 3. **Approve**, which makes the release installable.
737
+ 4. **Install.** The operator picks the avatars (for an avatar-scoped plugin),
738
+ reads what the plugin declares, and fills in the three configuration
739
+ sections. Install writes the menu and permission rows, provisions the schema,
740
+ applies the migrations and starts the backend — unless a required backend
741
+ setting is missing, in which case it lands in `awaiting_config`.
742
+ 5. **Upgrade** by installing a newer release over the same plugin id. The screen
743
+ shows what this version discloses beyond the accepted one. Stored
744
+ configuration is re-validated against the new schema.
745
+
746
+ A **downgrade is refused** before anything is written: a release that does not
747
+ contain a migration this tenant has already applied cannot be installed, because
748
+ the data those migrations produced cannot be rolled back automatically.
749
+ Uninstall first if the intent is to start over — which does drop the schema.
750
+
751
+ ### One command, for iteration and CI
752
+
753
+ Steps 1–4 are also a single non-interactive command. It packs the current tree,
754
+ streams the archive through the same presigned upload the screen uses, finalizes
755
+ the release, approves it, and installs it onto the tenant:
756
+
757
+ ```bash
758
+ pnpm exec tego-plugin publish \
759
+ --server http://localhost:3100 \
760
+ --token "$TEGO_ADMIN_TOKEN" \
761
+ --avatar av_123 # repeat per avatar; avatar-scoped plugins only
762
+ ```
763
+
764
+ `--server` and `--token` fall back to `TEGO_SERVER_URL` and `TEGO_ADMIN_TOKEN`.
765
+ The token is an admin session's bearer — the publish endpoints all sit behind
766
+ the same admin guard as the screen. `--no-approve` stops at a draft;
767
+ `--no-install` approves without installing. There is no separate downgrade or
768
+ config path here: those decisions belong on the screen, and this command
769
+ deliberately does not reach for them.
770
+
771
+ ### 通用 AI 表单填写(Host API 1.9)
772
+
773
+ 宿主为所有插件页面提供统一悬浮按钮。插件登记当前可编辑表单,无需实现 AI 输入框、模型调用或聊天窗口。支持 React、Vue 及原生前端;`frontend` 入口不依赖 React。
774
+
775
+ ```ts
776
+ const unregister = ctx.ai?.registerForm({
777
+ id: 'inventory-edit',
778
+ title: '库存调整',
779
+ element: formElement,
780
+ getSnapshot: () => ({
781
+ fields: [
782
+ { name: 'quantity', label: '数量', type: 'number', value: state.quantity },
783
+ { name: 'note', label: '说明', type: 'text', value: state.note },
784
+ ],
785
+ instructions: '数量使用件,不推测未提供的数量。',
786
+ }),
787
+ apply: values => updateLocalForm(values),
788
+ });
789
+ // 关闭表单/卸载组件时调用;不要在每次输入变化时重新注册。
790
+ unregister?.();
791
+ ```
792
+
793
+ `element` 必须已经挂载。React 的弹窗可能延迟挂载 portal,建议用 callback ref 取得元素,再在 effect 内登记。`getSnapshot` 必须读最新状态;React 可以用 ref 保存当前表单值。
794
+
795
+ 字段类型为 `text`、`number`、`boolean`、`date`、`select`;枚举提供 `options: [{ value, label }]`。只登记当前可见、可编辑、允许提供给分身的字段;不登记密码、令牌、隐藏字段。业务枚举和规则由插件声明,SDK 不内置任何具体插件的数据模型。
796
+
797
+ 宿主调用所选分身的独立 model-only 运行,使用完成回执回填,不发聊天消息、不赋予工具写权限。回填前检查字段类型和枚举,保留运行期间手工编辑的字段;关闭表单或切换分身会取消,迟到结果丢弃。嵌套表单优先作用于最后登记的表单,关闭后恢复上一张。
798
+
799
+ `apply` 只更新本地状态,不能提交或写数据库。用户继续通过原表单的校验和保存路径提交。没有登记表单的旧插件仍能显示统一入口,但会提示先打开已接入的表单。`ctx.ai` 是可选能力,可兼容旧宿主;需要保证可用的插件应声明 `hostApiRange: ^1.9.0`。
800
+
801
+ 单次填写通过有长度上限的运行回执返回,宿主要求简洁 JSON;过长、格式错误或不属于当前表单的输出会拒绝回填,不能保存部分损坏结果。
802
+
803
+ ## 受管下载(Host API 1.15)
804
+
805
+ Host API 1.20.0 增加可选 `host.diagrams`:安装声明 `diagrams.render` 后,
806
+ 调用 `status()` 检查渲染器就绪,再以 `render({ kind: 'mermaid' | 'html', source,
807
+ destinationKey, width?, height? })` 取得 PNG 的命名空间内 key、尺寸、哈希和渲染器版本。
808
+ 宿主不接收服务器路径或远程页面地址;HTML 只允许静态结构和受限样式,Mermaid 禁止
809
+ 配置覆盖与链接。渲染隔离、外网关闭、画布越界报错,默认限时 15 秒、并发 2。
810
+ 插件在后台任务保存幂等键、租约和章节版本,并重新校验项目权限后发布结果。
811
+
812
+ 需要下载时重新授权的插件声明 `storage.managed`,并使用
813
+ `host().storage.managedPath(key, policy, ttlSeconds)`;Python 对应
814
+ `host().storage.managed_path(key, policy, ttl_seconds)`。policy 包含主体、客户、
815
+ resourceType/resourceId 和 purpose(view 或 export)。有效期最多 300 秒。
816
+
817
+ 每次 GET 或 Range 请求开始前,宿主向固定
818
+ `POST /api/internal/storage/authorize` 发送带安装签名的策略。
819
+ 插件必须实时核验授权及 key 与资源的归属,并返回 `{ allowed: true }` 才能下载。
820
+ SDK 单独验签并提供 storage policy 上下文;该上下文没有普通用户或公开身份,
821
+ 且不能访问其他业务路由。旧 signedPath 继续兼容未声明受管存储的插件。
822
+
823
+ 该机制约束每次新下载;已经通过授权而开始的传输可以完成。策略回调失败、
824
+ 超时或拒绝时,宿主不打开文件流。
825
+
826
+ ### 打开插件的 Office 产物
827
+
828
+ Host API 1.21.0 提供可选 `ctx.office.open({ sourcePath, signal? })`。`sourcePath`
829
+ 只能指向当前插件自己的后端元数据接口;接口先校验业务权限,再返回
830
+ `{ fileName, sha256, sizeBytes, downloadPath }`。`downloadPath` 必须由
831
+ `storage.managedPath` 签发,宿主复核安装所属插件、实时下载权限、文件长度和 SHA-256,
832
+ 不接收本机路径、外部 URL 或资料库文档 ID。
833
+
834
+ Office 打开该字节快照的独立副本,保存不会写回插件正式产物。分页为近似效果,
835
+ 打印验收仍由对应 Office 应用完成。单次预览读取配额为 64 MiB,超过时应选择分册
836
+ 或下载到本地;此限制不影响插件保存和导出长文。旧宿主没有 `ctx.office`,插件须做
837
+ 能力检测并保留下载入口。
838
+
839
+ ### 直接注册普通用户
840
+
841
+ Host API 1.17.0 提供 `host.accounts.provisionUser`(Python:`host().accounts.provision_user`),需声明 `accounts.provision`。传入 `requestId`、`organizationName` 及用户身份;新用户同时传入 `password`,至少 8 位并包含大小写字母和数字。平台使用安装上下文的租户,复用配额和普通用户角色校验,在同一事务保存密码哈希及幂等结果,不发送邮件。已有用户可通过 `userId` 指定,或由平台按该租户邮箱匹配;已有账号的密码及身份资料不被覆盖。返回 `{ id, userId }`。插件须在调用前验证操作者的企业管理权限,且不持久化明文密码。
842
+
843
+ Host API 1.18.0 提供 host.accounts.resetPassword({ userId, newPassword })(Python:host().accounts.reset_password(user_id, new_password)),须声明 accounts.provision。租户由安装凭证确定;仅允许重置有效普通平台账号,平台管理员账号必须在 Tego 平台管理。密码至少 8 位并包含大小写字母和数字。宿主复用正式密码服务,本地账号下次登录必须修改密码,第三方登录账号按平台认证规则处理。插件必须先校验操作者对目标企业成员的管理权限,不能持久化或记录明文密码。
844
+
845
+ ### 用事件启动员工的定时任务
846
+
847
+ Host API 1.26.0 提供可选 `host.proactive.emit({ userId, eventType, eventId, occurredAtMs?, payload? })`(Python:`host().proactive.emit(user_id=..., event_type=..., event_id=...)`),须声明 `proactive.emit` 并有后端部分,`hostApiRange` 至少 `^1.26.0`。宿主把来源固定为 `plugin:<pluginId>`,只启动该用户在本租户已启用、`schedule` 为 `{ kind: 'event', source: 'plugin:<pluginId>', eventType }` 的定时任务;返回 `{ source, runs }`,每个命中的任务一条结果。同一 `eventId` 重发返回已排队的运行;任务正在运行时返回 `reason: 'already-running'`,本次不排队。`payload` 以外部不可信内容交给员工。详见 [plugin-sdk-api.md](../../plugins/docs/plugin-sdk-api.md#事件触发任务)。