Author a capability once. Reuse it everywhere.
A capability bundle packages a working slice of a swarm, its agents, their rules, workflows, and connector grants, into one reusable, versioned unit. Compose it in code, publish immutable versions, apply it into a tenant's live agents, and upgrade downstream, without trampling a tenant's own tuning.
What a bundle is
Think of it as an npm package, but for a working piece of a swarm instead of a function. A bundle composes multiple agents with their personas, models, skills, and delegate wiring, plus their rules, workflows, and connector grants. You author it once and reuse it across projects and tenants, then evolve it once and upgrade everywhere downstream.
Not to be confused with a skill. A defineSkill is also sometimes called a capability bundle, but that packages instructions, tools, rules, and workflows for one agent. A defineBundle composes multiple agents. Different unit.
Compose it, in code
At the source level a bundle is just code you import and pass to defineSwarm, so its rules and workflows keep their normal runtime home and its skill handles are present in-process. Composition is closure-validated at author time, no runtime surprises.
import { defineAgent, defineBundle, mergeBundle, defineSwarm } from "@nightowlsdev/core";
// A bundle composes defineAgent OUTPUTS + their rules/workflows + connector grants.
export const contentStudio = defineBundle({
slug: "content-studio",
agents: [editor, reviewer],
connectorGrants: [
// names only, never a token. The host resolves the credential at call time (per executing agent, FR-058).
{ agentSlug: "editor", provider: "slack", actions: ["post_message"] },
],
});
// Drop it into any swarm. Closure-validated at author time: a typo in a skill,
// delegate, rule, or workflow reference throws HERE, not as a runtime run_failed.
const swarm = defineSwarm(mergeBundle(baseConfig, contentStudio));Publish and version it
To distribute a bundle across tenants, serialize it to an immutable, append-only version in your Postgres. A published version can never be edited, history only moves forward, and a rollback republishes a prior snapshot as a new head.
import { toBundleContent } from "@nightowlsdev/core";
// Publish an immutable, append-only version a host can apply to many tenants later.
await store.bundlesWritable.publish(orgId, "content-studio", toBundleContent(contentStudio), actor);
// Read the head, a specific version, or the full history.
await store.bundles.head(orgId, "content-studio");
await store.bundlesWritable.listVersions(orgId, "content-studio", actor); // v1, v2, ...
// Roll the head back to an earlier version (records a NEW audit row, git revert, not reset).
await store.bundlesWritable.rollback(orgId, "content-studio", 1, actor);Apply into a tenant's live agents
Materialize a version into a tenant's running agents. Apply is same-org, content-aware (a member already at the target is left untouched, no version churn), and fail-closed: you pass a handle manifest, and if the bundle references a skill this deployment can't resolve, the whole apply aborts rather than silently shipping a no-op tool.
import { applyBundle } from "@nightowlsdev/storage-supabase";
// The host asserts which skill names actually resolve in THIS deployment (fail-closed).
const manifest = { has: (name: string) => myHostRegistry.has(name) };
// Materialize the bundle into the tenant's running agents, in one transaction.
const r = await applyBundle(store.ctx, {
tenantId, slug: "content-studio", version: 1, actor, manifest,
});
// -> { applied: [{ slug: "editor", version: 1 }, ...], unchanged: [], skipped: { rules, workflows } }
// A missing skill name aborts the WHOLE apply, no half-applied bundle.Upgrade downstream, without clobbering local tuning
This is the point of the whole thing. When you publish a new version, upgrade a tenant from its pinned version to the latest. For each member that changed, a divergence guard compares the tenant's live agent against the snapshot last applied. If it drifted, a manual edit, or an automatic optimization pass, the upgrade skips that member and reports it as diverged rather than overwriting the tenant's tuning. You decide, per member, whether to keep it or take the new baseline.
import { upgradeBundle } from "@nightowlsdev/storage-supabase";
// After you publish content-studio v2, roll each tenant forward.
const r = await upgradeBundle(store.ctx, {
tenantId, slug: "content-studio", actor, manifest,
});
// -> {
// fromVersion: 1, toVersion: 2, pinAdvanced: true,
// applied: [{ slug: "editor", version: 2 }], // only the members that changed
// diverged: [], // members whose live head drifted since apply (GEPA / manual), SKIPPED
// unsupportedRemovals: [], // a member the new version drops (agents aren't deleted in v1)
// skipped: { rules: false, workflows: false },
// }
// A diverged member is never clobbered. Consent to overwrite it explicitly:
await upgradeBundle(store.ctx, { tenantId, slug: "content-studio", actor, manifest,
confirmDiverged: ["editor"] });Example use case
You run an agency platform. You've perfected a two-agent Content Studio, an editor that drafts and can post to Slack, and a reviewer that fact-checks, for one client.
- Publish the crew as bundle content-studio v1.
- Onboard 50 tenants by calling applyBundle in a loop, each gets the editor + reviewer as live agents, Slack wired per-tenant, and the manifest guarantees no unresolved skills slip through.
- Improve the editor and publish content-studio v2.
- Roll it out with upgradeBundle per tenant. Clean tenants move to v2. But the 3 tenants whose editor was auto-optimized against their own traffic come back as diverged, so you never destroy a locally-tuned agent with a generic push. You choose per-tenant.
Central reuse and evolution, without breaking per-tenant self-improvement. That balance is the reason bundles exist.
When to reach for a bundle — and when not
A bundle is a distribution unit. Its whole reason to exist is the second half of the lifecycle: authoring a multi-agent capability once and rolling it forward across many tenants without trampling their local tuning. If you are not distributing across tenants or projects, you probably do not need one — and there are three neighbours it is easy to confuse it with.
| Reach for | When | Unit |
|---|---|---|
| defineSwarm code | a single deployment — one app, one org, no fan-out | your own source, versioned in git |
| defineSkill | packaging tools + instructions + rules for one agent | a capability grantable to an agent |
| a pre-built agent package | distributing a persona as an npm package of code | an AgentDef factory + curated skills |
| defineBundle | an org operates many tenants and wants multiple agents + wiring published, applied, and upgraded per tenant | an immutable, versioned unit in your DB |
The two are composable, not competing: a pre-built agent package hands you AgentDefs, and you can compose those (and your own) into a defineBundle you then publish to your tenants. Reach for a bundle when the divergence guard is the feature you want — the ability to push a v2 that skips the tenants who tuned their own copy.
What a v1 bundle does not carry
The honest edges, so you plan around them rather than discover them. None of these is a silent failure — each is reported in the result or rejected loudly.
- The persisted path materializes agents, not rules or workflows. A published bundle records its rules and workflows, but applyBundle / upgradeBundle do not push them into the running swarm — they return skipped: { rules, workflows } so a rules/workflows change is surfaced, never swallowed. Rules and workflows stay defineSwarm code config; you mirror that change in your own source. (In-process, via mergeBundle, they behave normally — this limit is only on the DB apply path.)
- Agents are never deleted. If a new version drops a member, the upgrade reports it under unsupportedRemovals and leaves the live agent in place — removals are a deliberate host decision in v1, not an automatic destructive push.
- The manifest is host-asserted, and fail-closed. You pass a { has(name) } manifest of the skills that actually resolve in this deployment; a bundle referencing an unresolvable skill aborts the whole apply. A pinned grant ("name@3") also needs the storedSkills resolver passed, or it is rejected rather than silently accepted.
- Connector grants are names, never tokens. A bundle carries { provider, actions } — the host resolves the actual credential at call time, per executing agent (FR-058). A published bundle is safe to move between tenants precisely because no secret rides inside it.
- Versions are append-only; the pin advances only on a clean run. Publish is immutable and history only moves forward — a rollback republishes a prior snapshot as a new head (git revert, not reset). An upgrade advances the tenant's pin only when it completes with no diverged or removed member; a partial upgrade leaves the pin where it was, so a re-run with confirmDiverged finishes the job. Apply and upgrade are same-org — a bundle is applied within the tenant that owns it.