ltcai 1.7.0 → 2.0.0

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.
@@ -0,0 +1,651 @@
1
+ # Lattice AI Plugin SDK
2
+
3
+ The Plugin SDK is the v2.0.0 extension layer for the Lattice AI Agentic Workspace
4
+ Platform. It lets you package skills, tools, workflow templates, and actions into
5
+ one versioned, permissioned unit — a *plugin*. A plugin is a directory under the
6
+ configured `plugins` root that ships a `plugin.json` manifest.
7
+
8
+ The SDK is intentionally **additive**. Plugins *extend* the existing Skill, Tool,
9
+ and Workflow surfaces; they never replace them. Standalone skills that are already
10
+ installed keep working untouched, and a plugin that bundles a skill registers it
11
+ through the existing skill registry rather than owning a parallel one.
12
+
13
+ > **Compatibility.** v1.x data and APIs are preserved. The Plugin SDK adds new
14
+ > code (`latticeai/core/plugins.py`, `latticeai/api/plugins.py`) and new
15
+ > persisted state (`plugin_registry` inside the Workspace OS store). Nothing in
16
+ > the v1.x contract changes. The new HTTP routes live under `/plugins/registry`
17
+ > and friends, which do **not** collide with the pre-existing
18
+ > `/plugins/directory` marketplace routes.
19
+
20
+ The host SDK version is exposed as:
21
+
22
+ ```python
23
+ PLUGIN_SDK_VERSION = "2.0.0"
24
+ ```
25
+
26
+ ---
27
+
28
+ ## The `plugin.json` manifest
29
+
30
+ Every plugin ships a `plugin.json` at the root of its directory. The manifest is
31
+ parsed and validated into an immutable `PluginManifest`.
32
+
33
+ ### Schema
34
+
35
+ | Field | Type | Required | Notes |
36
+ | --- | --- | --- | --- |
37
+ | `id` | string | yes | Lowercase alphanumeric with `-` or `_`, 2–64 chars (`^[a-z0-9][a-z0-9_-]{1,63}$`). Directory name should match. |
38
+ | `name` | string | yes | Human-readable name. Falls back to `id` if omitted. |
39
+ | `version` | string | yes | Semantic version (`^\d+\.\d+\.\d+([.-][0-9A-Za-z.]+)?$`). |
40
+ | `description` | string | no | Short summary. |
41
+ | `author` | string | no | Author or organization. |
42
+ | `lattice_version` | string | no | Minimum host version this plugin requires. May be bare (`"2.0.0"`) or prefixed (`">=2.0.0"`). Empty means "any host". |
43
+ | `permissions` | string[] | no | Must be a subset of the [permission allow-list](#permissions). Unknown values are rejected. |
44
+ | `provides` | object | no | What the plugin contributes. Keys must be in `("skills", "tools", "workflows", "actions")`; each value is a list of names. |
45
+ | `entrypoint` | string | no | Reserved for an optional code entrypoint. |
46
+ | `homepage` | string | no | Project / docs URL. |
47
+
48
+ The `provides` object declares the surfaces the plugin extends:
49
+
50
+ ```json
51
+ {
52
+ "provides": {
53
+ "skills": ["..."],
54
+ "tools": ["..."],
55
+ "workflows": ["..."],
56
+ "actions": ["..."]
57
+ }
58
+ }
59
+ ```
60
+
61
+ The allowed keys are fixed:
62
+
63
+ ```python
64
+ PLUGIN_PROVIDES = ("skills", "tools", "workflows", "actions")
65
+ ```
66
+
67
+ ### Validation rules
68
+
69
+ `validate_manifest(data, *, path="")` returns `(manifest_or_None, errors)`. A
70
+ manifest with any error returns `None` for the manifest, so a caller can never
71
+ accidentally treat an invalid plugin as runnable. The validator enforces:
72
+
73
+ - `id`, `name`, and `version` are present.
74
+ - `id` matches the id pattern; `version` is a valid semantic version.
75
+ - `permissions` is a list and every entry is in `PLUGIN_PERMISSIONS`.
76
+ - `provides` is an object, every key is in `PLUGIN_PROVIDES`, and every value is
77
+ a list.
78
+ - If `lattice_version` is set, the host must be compatible (see
79
+ [version compatibility](#version-compatibility)).
80
+
81
+ ```python
82
+ def validate_manifest(
83
+ data: Dict[str, Any], *, path: str = ""
84
+ ) -> Tuple[Optional[PluginManifest], List[str]]:
85
+ ...
86
+ ```
87
+
88
+ ### Parsed manifest
89
+
90
+ ```python
91
+ @dataclass(frozen=True)
92
+ class PluginManifest:
93
+ id: str
94
+ name: str
95
+ version: str
96
+ description: str = ""
97
+ author: str = ""
98
+ lattice_version: str = ""
99
+ permissions: Tuple[str, ...] = ()
100
+ provides: Dict[str, List[str]] = field(default_factory=dict)
101
+ entrypoint: str = ""
102
+ homepage: str = ""
103
+ path: str = ""
104
+ ```
105
+
106
+ `PluginManifest.public()` returns the JSON-safe shape used by the API, including a
107
+ computed `compatible` flag and the on-disk `path`:
108
+
109
+ ```json
110
+ {
111
+ "id": "hello-world",
112
+ "name": "Hello World",
113
+ "version": "1.0.0",
114
+ "description": "...",
115
+ "author": "Lattice AI",
116
+ "lattice_version": "2.0.0",
117
+ "permissions": ["read_workspace", "run_skills"],
118
+ "provides": {
119
+ "skills": ["hello_skill"],
120
+ "workflows": ["hello-workflow"],
121
+ "actions": ["greet"]
122
+ },
123
+ "entrypoint": "",
124
+ "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI/blob/main/docs/PLUGIN_SDK.md",
125
+ "path": "/abs/path/to/plugins/hello-world",
126
+ "compatible": true
127
+ }
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Permissions
133
+
134
+ Permissions are a small, fixed allow-list. A manifest may only request
135
+ permissions from this set; the execution boundary refuses any capability the
136
+ plugin did not declare *and* was not granted at install time.
137
+
138
+ ```python
139
+ PLUGIN_PERMISSIONS = (
140
+ "read_workspace",
141
+ "write_workspace",
142
+ "read_graph",
143
+ "write_graph",
144
+ "run_tools",
145
+ "run_skills",
146
+ "run_workflows",
147
+ "run_agents",
148
+ "network",
149
+ "manage_memory",
150
+ )
151
+ ```
152
+
153
+ | Permission | Grants |
154
+ | --- | --- |
155
+ | `read_workspace` | Read workspace state. |
156
+ | `write_workspace` | Mutate workspace state. |
157
+ | `read_graph` | Read the knowledge graph. |
158
+ | `write_graph` | Write to the knowledge graph. |
159
+ | `run_tools` | Invoke tools (required by the `run_tool` action). |
160
+ | `run_skills` | Invoke skills (required by the `run_skill` action). |
161
+ | `run_workflows` | Invoke workflows (required by the `run_workflow` action). |
162
+ | `run_agents` | Invoke agents (required by the `run_agent` action). |
163
+ | `network` | Make outbound network calls. |
164
+ | `manage_memory` | Read/write persistent memory. |
165
+
166
+ The list is kept deliberately small so the Enterprise seam can layer
167
+ finer-grained policy on top without changing the community contract. Each
168
+ permission maps to a concrete thing the [execution boundary](#permissioned-execution-boundary)
169
+ will or will not allow.
170
+
171
+ ---
172
+
173
+ ## Version compatibility
174
+
175
+ A plugin's `lattice_version` is treated as a **minimum**: the host major version
176
+ must match, and the host version must be greater than or equal to the required
177
+ version. An empty/missing requirement means "any host".
178
+
179
+ ```python
180
+ def is_compatible(required: str, current: str = PLUGIN_SDK_VERSION) -> bool:
181
+ """True if a plugin requiring ``required`` runs on ``current`` host version.
182
+
183
+ ``required`` is a minimum (major must match, host must be >= required).
184
+ Empty / missing requirement is treated as "any host".
185
+ """
186
+ ```
187
+
188
+ A leading `>=` is stripped before comparison, so both forms below behave
189
+ identically:
190
+
191
+ ```json
192
+ { "lattice_version": "2.0.0" }
193
+ { "lattice_version": ">=2.0.0" }
194
+ ```
195
+
196
+ Examples against a host of `2.0.0`:
197
+
198
+ | Required | Compatible | Why |
199
+ | --- | --- | --- |
200
+ | `""` (missing) | yes | Any host. |
201
+ | `2.0.0` / `>=2.0.0` | yes | Same major, host `>=` required. |
202
+ | `2.1.0` | no | Host is lower than the required minimum. |
203
+ | `1.0.0` | no | Major mismatch. |
204
+ | `3.0.0` | no | Major mismatch. |
205
+
206
+ Compatibility is checked at validation time and again at install time, so an
207
+ incompatible plugin can never be activated.
208
+
209
+ ---
210
+
211
+ ## Lifecycle
212
+
213
+ ```
214
+ discover -> validate -> install -> enable / disable -> uninstall
215
+ ```
216
+
217
+ Lifecycle *state* (installed / enabled / version / status) is delegated to the
218
+ Workspace OS store via a small `store` port, so plugins reuse the same
219
+ local-first JSON persistence, workspace scoping, and timeline events as skills.
220
+ The registry itself owns only manifest parsing and the execution boundary.
221
+
222
+ ```python
223
+ class PluginRegistry:
224
+ def __init__(self, plugins_dir: Path | str, *, store: Any = None): ...
225
+ ```
226
+
227
+ ### Discover
228
+
229
+ `discover()` scans `plugins_dir` for `<id>/plugin.json` manifests. It performs no
230
+ import-time I/O — the filesystem is only touched when you call it. Each manifest
231
+ is validated; results are split into `valid` and `invalid`.
232
+
233
+ ```python
234
+ def discover(self) -> Dict[str, Any]:
235
+ # {"valid": List[PluginManifest], "invalid": List[{path, id?, errors}]}
236
+ ...
237
+ ```
238
+
239
+ `catalog()` merges discovered manifests with persisted lifecycle state for the
240
+ UI, returning `sdk_version`, the permission/provides vocabularies, the merged
241
+ `plugins` list, any `invalid` entries, `plugins_dir`, and `total`.
242
+
243
+ ### Validate
244
+
245
+ A manifest can be validated standalone (without touching disk) via
246
+ `validate_manifest` or over HTTP via `POST /plugins/validate`.
247
+
248
+ ### Install
249
+
250
+ `install(plugin_id, *, register_skill=None)` activates a discovered plugin. It
251
+ re-checks compatibility, registers the skills the plugin bundles through the
252
+ **existing** skill registry, and records lifecycle state in the store.
253
+
254
+ ```python
255
+ def install(
256
+ self,
257
+ plugin_id: str,
258
+ *,
259
+ register_skill: Optional[Callable[[str, str], Any]] = None,
260
+ ) -> Dict[str, Any]:
261
+ ...
262
+ ```
263
+
264
+ `register_skill(skill_name, plugin_id)` is injected by the host so plugins
265
+ *extend* the existing skill registry instead of owning a parallel one. For each
266
+ name under `provides.skills`, the registry calls `register_skill`; registration
267
+ is best-effort (a failure for one skill does not abort the install). The returned
268
+ dict contains the public manifest, the persisted `registry` entry, and the list
269
+ of `registered_skills`.
270
+
271
+ Install persists this entry into the store's `plugin_registry`:
272
+
273
+ ```python
274
+ entry = self.store.mark_plugin_installed(
275
+ plugin_id,
276
+ version=manifest.version,
277
+ metadata={
278
+ "permissions": list(manifest.permissions),
279
+ "provides": {k: list(v) for k, v in manifest.provides.items()},
280
+ "registered_skills": registered_skills,
281
+ },
282
+ )
283
+ ```
284
+
285
+ The granted permissions are captured in `metadata.permissions` at this point —
286
+ this is the "granted at install time" set the execution boundary enforces.
287
+
288
+ If the plugin is missing/invalid, or its `lattice_version` is incompatible with
289
+ the host, `install` raises `PluginError`.
290
+
291
+ ### Enable / Disable
292
+
293
+ ```python
294
+ def set_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]: ...
295
+ ```
296
+
297
+ A disabled plugin remains installed but cannot execute actions (the boundary
298
+ returns `blocked`). Enable/disable is a toggle on the persisted registry entry.
299
+
300
+ ### Uninstall
301
+
302
+ ```python
303
+ def uninstall(self, plugin_id: str) -> Dict[str, Any]: ...
304
+ ```
305
+
306
+ Uninstall marks the registry entry `installed: false` and `enabled: false`. It is
307
+ non-destructive to the on-disk plugin directory; re-installing re-activates it.
308
+
309
+ ### Persisted state
310
+
311
+ State lives in `WorkspaceOSStore.plugin_registry`, a dict keyed by `plugin_id`.
312
+ The store methods used by the registry are:
313
+
314
+ ```python
315
+ def list_plugin_registry(self) -> Dict[str, Any]: ...
316
+ def mark_plugin_installed(self, plugin_id, *, version="0.0.0",
317
+ metadata=None) -> Dict[str, Any]: ...
318
+ def mark_plugin_uninstalled(self, plugin_id) -> Dict[str, Any]: ...
319
+ def set_plugin_enabled(self, plugin_id, enabled) -> Dict[str, Any]: ...
320
+ ```
321
+
322
+ A persisted entry looks like:
323
+
324
+ ```json
325
+ {
326
+ "id": "hello-world",
327
+ "installed": true,
328
+ "enabled": true,
329
+ "version": "1.0.0",
330
+ "install_status": "ready",
331
+ "validation_status": "valid",
332
+ "metadata": {
333
+ "permissions": ["read_workspace", "run_skills"],
334
+ "provides": {
335
+ "skills": ["hello_skill"],
336
+ "workflows": ["hello-workflow"],
337
+ "actions": ["greet"]
338
+ },
339
+ "registered_skills": ["hello_skill"]
340
+ },
341
+ "updated_at": "..."
342
+ }
343
+ ```
344
+
345
+ Each lifecycle mutation also records a Workspace OS timeline event
346
+ (`plugin_installed`, `plugin_uninstalled`, `plugin_enabled`, `plugin_disabled`)
347
+ under the `plugins` channel.
348
+
349
+ ---
350
+
351
+ ## Permissioned execution boundary
352
+
353
+ Plugin actions never run arbitrary code directly. They pass through
354
+ `execute_action`, which enforces enablement and permissions, then dispatches to a
355
+ host-provided runner.
356
+
357
+ ```python
358
+ def execute_action(
359
+ self,
360
+ plugin_id: str,
361
+ action: str,
362
+ args: Optional[Dict[str, Any]] = None,
363
+ *,
364
+ runners: Optional[Dict[str, Callable[..., Any]]] = None,
365
+ ) -> PluginExecutionResult:
366
+ ...
367
+ ```
368
+
369
+ `runners` maps a capability (`"tools"`, `"skills"`, `"workflows"`, `"agents"`) to
370
+ a callable the host injects. Built-in actions map to a capability and the
371
+ permission they require:
372
+
373
+ | Action | Capability | Required permission |
374
+ | --- | --- | --- |
375
+ | `run_tool` | `tools` | `run_tools` |
376
+ | `run_skill` | `skills` | `run_skills` |
377
+ | `run_workflow` | `workflows` | `run_workflows` |
378
+ | `run_agent` | `agents` | `run_agents` |
379
+ | *(any other)* | `actions` | *(none)* |
380
+
381
+ The boundary applies these checks in order:
382
+
383
+ 1. **Unknown / invalid plugin** → `status: "error"` (`plugin not found or invalid`).
384
+ 2. **Disabled plugin** → `status: "blocked"` (`plugin is not enabled`). When a
385
+ store is wired, the plugin must be enabled (falling back to `installed`).
386
+ 3. **Permission not declared in the manifest** → `status: "blocked"`
387
+ (`plugin did not declare required permission '...'`).
388
+ 4. **Permission not granted at install time** → `status: "blocked"`
389
+ (`permission '...' not granted at install time`). The granted set comes from
390
+ the persisted `metadata.permissions`.
391
+ 5. **No host runner wired for the capability** → `status: "skipped"`
392
+ (`no host runner for capability '...'`). This is safe-by-default: a missing
393
+ runner never crashes the caller.
394
+ 6. Otherwise the runner is invoked. Success → `status: "ok"` with `output`; a
395
+ raised exception → `status: "error"` with `reason`.
396
+
397
+ The runner is called as:
398
+
399
+ ```python
400
+ output = runner(plugin_id=plugin_id, action=action, args=args, manifest=manifest)
401
+ ```
402
+
403
+ ### Result shape
404
+
405
+ ```python
406
+ @dataclass
407
+ class PluginExecutionResult:
408
+ plugin_id: str
409
+ action: str
410
+ status: str # "ok" | "blocked" | "error" | "skipped"
411
+ output: Any = None
412
+ reason: str = ""
413
+ ```
414
+
415
+ `as_dict()` serializes it for the API:
416
+
417
+ ```json
418
+ {
419
+ "plugin_id": "git-insights",
420
+ "action": "run_tool",
421
+ "status": "ok",
422
+ "output": "...",
423
+ "reason": ""
424
+ }
425
+ ```
426
+
427
+ `PluginError` is raised for validation / lifecycle / execution failures at the
428
+ registry level (for example, installing a missing or incompatible plugin).
429
+
430
+ ---
431
+
432
+ ## HTTP API
433
+
434
+ The API router is built with the same router-factory convention as the rest of
435
+ `latticeai.api`: `server_app` constructs the dependencies and passes them in; the
436
+ module never imports the app.
437
+
438
+ ```python
439
+ def create_plugins_router(
440
+ *,
441
+ registry,
442
+ require_user: Callable[[Request], str],
443
+ require_admin: Callable[[Request], Any],
444
+ append_audit_event: Callable[..., None],
445
+ register_skill: Optional[Callable[[str, str], Any]] = None,
446
+ plugin_runners_factory: Optional[Callable[[], Dict[str, Callable[..., Any]]]] = None,
447
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
448
+ static_dir: Optional[Path] = None,
449
+ ) -> APIRouter:
450
+ ...
451
+ ```
452
+
453
+ > All paths are namespaced under `/plugins/registry` (and sibling action routes)
454
+ > so they do **not** collide with the pre-existing `/plugins/directory`
455
+ > marketplace routes.
456
+
457
+ ### `GET /plugins/registry`
458
+
459
+ Requires an authenticated user. Returns the full `catalog()` (SDK version,
460
+ permission/provides vocabularies, merged plugin list, invalid manifests,
461
+ `plugins_dir`, total).
462
+
463
+ ### `GET /plugins/registry/{plugin_id}`
464
+
465
+ Requires an authenticated user. Returns the public manifest plus the persisted
466
+ registry state. `404` if the plugin is not found or invalid.
467
+
468
+ ```json
469
+ {
470
+ "plugin": { "...PluginManifest.public()..." },
471
+ "registry": { "...persisted entry..." }
472
+ }
473
+ ```
474
+
475
+ ### `POST /plugins/validate`
476
+
477
+ Requires an authenticated user. Validates a manifest dict without touching disk.
478
+
479
+ ```json
480
+ // request
481
+ { "manifest": { "id": "...", "name": "...", "version": "1.0.0" } }
482
+
483
+ // response
484
+ {
485
+ "ok": true,
486
+ "errors": [],
487
+ "manifest": { "...public()..." }
488
+ }
489
+ ```
490
+
491
+ ### `POST /plugins/install`
492
+
493
+ Requires **admin**. Installs the plugin (registering bundled skills via the
494
+ injected `register_skill`) and appends a `plugin_install` audit event. Returns
495
+ the install result. `400` on failure (`PluginError` message).
496
+
497
+ ```json
498
+ { "plugin_id": "hello-world" }
499
+ ```
500
+
501
+ ### `POST /plugins/uninstall`
502
+
503
+ Requires **admin**. Uninstalls the plugin and appends a `plugin_uninstall` audit
504
+ event.
505
+
506
+ ```json
507
+ { "plugin_id": "hello-world" }
508
+ ```
509
+
510
+ ### `POST /plugins/enable`
511
+
512
+ Requires an authenticated user. Enables the plugin.
513
+
514
+ ```json
515
+ { "plugin_id": "hello-world" }
516
+ ```
517
+
518
+ ### `POST /plugins/disable`
519
+
520
+ Requires an authenticated user. Disables the plugin.
521
+
522
+ ```json
523
+ { "plugin_id": "hello-world" }
524
+ ```
525
+
526
+ ### `POST /plugins/execute`
527
+
528
+ Requires an authenticated user. Runs an action through the
529
+ [execution boundary](#permissioned-execution-boundary), using runners from
530
+ `plugin_runners_factory()` (or an empty map if no factory is wired). Appends a
531
+ `plugin_execute` audit event including the resulting status.
532
+
533
+ ```json
534
+ // request
535
+ {
536
+ "plugin_id": "git-insights",
537
+ "action": "run_tool",
538
+ "args": { "tool": "git_status" }
539
+ }
540
+
541
+ // response — PluginExecutionResult.as_dict()
542
+ {
543
+ "plugin_id": "git-insights",
544
+ "action": "run_tool",
545
+ "status": "ok",
546
+ "output": "...",
547
+ "reason": ""
548
+ }
549
+ ```
550
+
551
+ ### `GET /plugins/sdk`
552
+
553
+ Requires an authenticated user. Serves the Plugin SDK UI page (`plugins.html`).
554
+ Returns `404` if the UI response helper / static directory are not wired or the
555
+ page is missing.
556
+
557
+ ### Request models
558
+
559
+ ```python
560
+ class PluginActionRequest(BaseModel):
561
+ plugin_id: str
562
+ enabled: Optional[bool] = None
563
+ version: Optional[str] = None
564
+
565
+ class PluginValidateRequest(BaseModel):
566
+ manifest: Dict[str, Any] = {}
567
+
568
+ class PluginExecuteRequest(BaseModel):
569
+ plugin_id: str
570
+ action: str
571
+ args: Dict[str, Any] = {}
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Bundled example plugins
577
+
578
+ Two example plugins ship with the platform under the `plugins` root.
579
+
580
+ ### `hello-world`
581
+
582
+ Demonstrates bundling a skill, a workflow template, and a custom action. It
583
+ requests only `read_workspace` and `run_skills`.
584
+
585
+ ```json
586
+ {
587
+ "id": "hello-world",
588
+ "name": "Hello World",
589
+ "version": "1.0.0",
590
+ "description": "Example plugin demonstrating the Lattice AI Plugin SDK: bundles a skill, a workflow template, and a greet action.",
591
+ "author": "Lattice AI",
592
+ "lattice_version": "2.0.0",
593
+ "permissions": ["read_workspace", "run_skills"],
594
+ "provides": {
595
+ "skills": ["hello_skill"],
596
+ "workflows": ["hello-workflow"],
597
+ "actions": ["greet"]
598
+ },
599
+ "entrypoint": "",
600
+ "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI/blob/main/docs/PLUGIN_SDK.md"
601
+ }
602
+ ```
603
+
604
+ On install, `hello_skill` is registered through the existing skill registry. The
605
+ `greet` action maps to the generic `actions` capability (no permission gate); it
606
+ runs only when the host wires an `actions` runner, otherwise it is reported
607
+ `skipped`.
608
+
609
+ ### `git-insights`
610
+
611
+ Surfaces read-only git status and log insights through the permissioned tool
612
+ execution boundary. It declares its requirement as `">=2.0.0"`, demonstrating the
613
+ prefixed form of `lattice_version`.
614
+
615
+ ```json
616
+ {
617
+ "id": "git-insights",
618
+ "name": "Git Insights",
619
+ "version": "1.0.0",
620
+ "description": "Example plugin that surfaces read-only git status and log insights through the permissioned tool execution boundary.",
621
+ "author": "Lattice AI",
622
+ "lattice_version": ">=2.0.0",
623
+ "permissions": ["read_workspace", "run_tools"],
624
+ "provides": {
625
+ "tools": ["git_status", "git_log"],
626
+ "actions": ["summarize_repo"]
627
+ },
628
+ "entrypoint": "",
629
+ "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI/blob/main/docs/PLUGIN_SDK.md"
630
+ }
631
+ ```
632
+
633
+ Because it declares `run_tools`, a `run_tool` action passes the permission gate
634
+ (provided `run_tools` was also granted at install time) and dispatches to the
635
+ host's `tools` runner. Without a `tools` runner wired, the same call is reported
636
+ `skipped` rather than failing.
637
+
638
+ ---
639
+
640
+ ## Authoring checklist
641
+
642
+ 1. Create a directory `plugins/<id>/` matching your `id`.
643
+ 2. Add a `plugin.json` with `id`, `name`, and `version` at minimum.
644
+ 3. Request only the permissions you need (subset of `PLUGIN_PERMISSIONS`).
645
+ 4. Declare what you `provide` (`skills` / `tools` / `workflows` / `actions`).
646
+ 5. Set `lattice_version` to your minimum supported host (e.g. `">=2.0.0"`).
647
+ 6. Validate via `POST /plugins/validate` (or `validate_manifest`).
648
+ 7. Install (admin), enable, and execute actions through `POST /plugins/execute`.
649
+
650
+ Remember: plugins **extend** existing skills, tools, and workflows — they never
651
+ replace them, and all v1.x data and APIs remain intact.