Plugin Bundles
Versioned npm packages that install entity types, agents, tasks, tools, views, blocks, nav, skills, and seed data into an Amble workspace.
Overview
A plugin bundle is a versioned npm package — @amble/<venture> or
@<venture-org>/<bundle> — whose bundle.manifest.json declares an
additive set of: entity types, agents, tasks, tool refs, view configs,
block registrations, nav patches, skill refs, theme tokens, and seed
data. Installation fans the manifest into the existing platform tables
in a single Postgres transaction. Every installed row is tagged with
installed_by_bundle, bundle_version, and (for workspace-scoped
sections) installed_by_workspace_id so upgrades, uninstalls, and
audits are trivial.
Architectural decisions:
- bundles are additive overlays, not fork replacements — see ADR-0007;
- the install boundary is the workspace, not the tenant — see ADR-0008.
Status: PR 1 (manifest schema + parser + ADR) and PR 2 (install /
uninstall SQL — workspace-scoped) shipped. PRs 3 (API routes + perms +
template adapter wire-up), 4 (gallery convergence into
/admin/workspaces/install), 5 (first reference bundle), and 6
(upgrade + curated registry) follow.
Where to find it: Admin > Workspaces > Install
(/admin/workspaces/install). The install gallery in PR #931 is the
single entry point — first-party templates and external bundles share
the same gallery and the same install pathway.
Who can use it: tenant admins (install / upgrade / uninstall via
workspaces.team.manage); system admins (manage curated registry).
Key Concepts
| Concept | Description |
|---|---|
| Bundle | A versioned npm package that declares additive platform configuration via bundle.manifest.json. |
| Manifest | The Zod-validated JSON document at the root of the package. Sole contract between bundle author and the install transaction. |
| Install transaction | install_bundle(tenant_id, workspace_id, manifest, options) — single Postgres transaction that fans manifest sections into entity_types, agents, tasks, views. |
installed_by_bundle | Column on every owned table. Lets us list, diff, upgrade, and uninstall a bundle's footprint atomically. |
installed_by_workspace_id | Column on every owned table. NULL = tenant-scoped (shared across workspaces); UUID = owned by that workspace's install of this bundle. |
| Install scope | Per-section install_scope: 'workspace' | 'tenant'. Default workspace. Tenant-scoped sections are idempotent across re-installs into other workspaces of the same tenant. |
| Per-workspace scoping | Bundles install per-workspace. The same bundle can be live in marketing workspace and absent in sales workspace within the same tenant. |
| Runtime entry point | Compiled JS in the bundle's npm package that registers tools, blocks, and skills at app boot. The DB row is the install marker; the package is the code. |
Manifest Reference
The manifest lives at features/bundles/lib/manifest.ts and is parsed
by parseBundleManifest() (or its non-throwing variant
safeParseBundleManifest()).
Identity
{
name: "@amble/demo-tracker", // scoped npm-style name
version: "0.1.0", // strict semver
displayName: "Demo Tracker",
description: "...", // optional
author: { name: "...", url: "..." }, // optional
license: "MIT", // optional
homepage: "...", // optional
}Platform compatibility
{
amblePlatform: {
minVersion: ">=0.1.0",
maxVersion: "<2.0.0", // optional
},
}Sections (all optional)
| Section | Becomes a row in / triggers |
|---|---|
entityTypes | public.entity_types |
agents | public.agents |
tasks | public.actions (action registry) |
tools | runtime registration via registerTool() |
views | public.views |
blocks | runtime registration via registerBlock() |
nav | merged into nav_config jsonb |
skills | runtime registration via registerSkill() |
theme | merged into tenant_settings.theme_tokens |
seedData | initial rows in public.entities + public.entity_relations |
Seed data — entities + relations
Bundles ship starter content via seedData. Both arrays are optional;
caps are 1000 entities + 2000 relations per bundle (more than that
should ship a runtime initializer, not the manifest).
{
seedData: {
entities: [
{
entity_type_slug: "project",
slug: "alpha", // required — idempotency key
name: "Project Alpha",
fields: { stage: "draft" }, // → entity.content jsonb
lockedFields: ["stage"], // → entity.metadata.lockedFields
},
{
entity_type_slug: "project",
slug: "beta",
name: "Project Beta",
content: { description: "..." }, // alternative to `fields`
},
],
relations: [
{
source_entity_type_slug: "project",
source_slug: "alpha",
predicate: "depends_on", // → relationship_type
target_entity_type_slug: "project",
target_slug: "beta",
},
],
},
}install_bundle() resolves entity_type_slug against tenant entity
types and resolves relation endpoints by (entity_type_slug, slug)
against tenant entities (whether seeded by THIS bundle in the same
transaction or pre-existing). Type-scoped resolution is required
because entities.slug is unique only within
(tenant_id, entity_type_slug) — a bundle can ship two entities with
the same slug across different entity types, so resolution by slug
alone would pick non-deterministically. Idempotency keys:
(tenant_id, entity_type_slug, slug) for entities (enforced by
uq_entities_tenant_type_slug + ON CONFLICT DO NOTHING),
(tenant_id, from_entity_id, to_entity_id, relationship_type) for
relations (enforced by uq_entity_relations_unique_relation +
ON CONFLICT DO NOTHING). Re-running install on the same manifest
produces the same N entities and same M relations.
Missing endpoints abort the install with PG error code P0001 so
typo'd slugs surface at install time instead of silently shipping a
disconnected graph.
Runtime + permissions
{
runtime: {
main: "dist/runtime.js",
register: "registerBundle",
},
permissions: ["entities.team.read", "entities.team.create"],
dependencies: { "@amble/portfolio-base": "^1.0.0" },
}The permissions field uses the same app_permission enum as user
roles. A pgTAP test in PR 2 enforces parity between the manifest's
permission union and the DB enum.
How It Works
┌──────────────────────────────────────────────────────────────────┐
│ npm registry │
│ @amble/rock-hill@1.3.0 @amble/ember@2.0.0 @amble/ims@0.4.0 │
└────────────────────┬─────────────────────────────────────────────┘
│ fetch + verify (PR 3)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Bundle resolver │
│ - npm tarball fetch + sha verify │
│ - manifest.json parse via parseBundleManifest() │
│ - dependency resolution + platform version check │
└────────────────────┬─────────────────────────────────────────────┘
│ preview / apply
▼
┌──────────────────────────────────────────────────────────────────┐
│ install_bundle(tenant_id, manifest, options) [SQL fn — PR 2] │
│ atomic transaction: │
│ INSERT INTO entity_types (... installed_by_bundle, version) │
│ INSERT INTO agents (... installed_by_bundle, version) │
│ INSERT INTO tasks (... installed_by_bundle, version) │
│ UPDATE nav_config (... merge bundle nav patches) │
│ INSERT INTO views (... installed_by_bundle, version) │
│ INSERT INTO bundle_installations (...) │
└────────────────────┬─────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Runtime registration (boot-time) │
│ - Bundle code loaded from `node_modules/<package>/dist/...` │
│ - registerTool() / registerBlock() / registerSkill() │
│ - Tenant scoping via existing `installed_by_bundle` filter │
└──────────────────────────────────────────────────────────────────┘API Reference
import {
parseBundleManifest,
safeParseBundleManifest,
findIntraBundleSlugCollisions,
type BundleManifest,
} from "@/features/bundles/lib/manifest"| Function | Use when |
|---|---|
parseBundleManifest(data) | Strict parse; throws ZodError on invalid input. CLI / install API entry point. |
safeParseBundleManifest(data) | Non-throwing parse; returns { success, data | error }. Use in admin preview UI. |
findIntraBundleSlugCollisions(manifest) | Read-only structural lint — duplicate slugs in the same section. |
Server-side install / uninstall functions (installBundle(),
uninstallBundle(), previewBundle()) ship in PR 2 under
features/bundles/server/.
For Agents
Bundles are the canonical way to ship a venture into Amble. When an agent is asked "add the Rock Hill schema to this tenant" or "install the demo tracker for me," the agent should:
- Call
previewBundle(packageName)to render the diff. - Surface the preview to the user via the admin UI or chat.
- On confirmation, call
installBundle(packageName)(PR 2 surface) or POST/api/bundles/install(PR 3+ surface).
For authoring a new bundle (rare today; common once bundle authoring DX matures in PR 6+), follow the manifest reference above and ship the runtime entry point as compiled JS.
Design Decisions
- Plugin overlay, not fork-replace. See ADR-0007.
- No new primitive. Bundles fan into the existing six (Record,
Agent, Action, Work, Message, Context).
bundle_installationsis a thin install-record table, not a 7th primitive. .passthrough()during PRs 1-3. The Zod schemas use.passthrough()so downstream PRs can append optional fields without breaking already-published bundles. Locked to.strict()before PR 4 ships.- Runtime ref, not inline code. The manifest declares
runtime_refstrings (e.g.,dist/tools/foo.js#registerTool); the actual code lives in the npm package. Mirrors theagent_connectionspattern. - Per-tenant by default. A bundle installed by
tenant-ais invisible totenant-beven when both tenants share the same Amble deployment.
Related Modules
- Agent System — bundles seed agents and assign role permissions.
- Tasks — bundles seed action registry rows.
- View System — bundles ship view configs against the unified surface registry.
- Block System — bundles register custom block components.
- Multi-tenant — bundles install per-tenant via the same RLS / GUC pattern as everything else.
- Interactivity — the four declarative Specs that spec-payload bindings (and the slot resolver) interpret.