Skip to content

Extensions

Cinatra extends through extensions: versioned, published packages that an admin installs into the workspace. The platform owns their lifecycle — install, update, uninstall, archive, restore, and (under explicit admin gate) force-delete — and tracks where they came from, who can see them, and how to read or publish them back to the right registry.

This document is the reference for the extension lifecycle and the registry routing it depends on. For the conventions around building an extension as a TypeScript package, see Building packages. For the authoring loop on the OAS Flow file inside an agent extension, see Developing agents.


The platform registers four extension type handlers at startup: agent packages, skill packages, connector packages, and artifact packages.

  • Agent extension — an OAS Flow agent published as an npm-style package. Carries oas.json (the agent definition), a sibling package.json (npm metadata + Cinatra metadata under metadata.cinatra), and any inline skill bundles the agent declares. Installing it upserts an agent_templates row, creates an agent_template_versions snapshot, registers any object types the agent declares, and (when present) installs declared package skills.

  • Skill extension — a GitHub-hosted SKILL.md package. Installing it persists a skill_packages row and triggers a skill-match recompute so the new skills are reachable from agents that match their criteria.

  • Artifact extension — a metadata-only package declaring a content type. Carries cinatra.kind: "artifact" plus an artifact block describing its MIME allowlist and the matcher/authoring skills that resolve it (artifact.skills.matchers, artifact.skills.authoring). Registered with ArtifactExtensionTypeHandler, which forbids cinatra/oas.json so the agent loader never mounts it. See artifacts-architecture.md.

  • Connector extension — a transport / external-system integration package living at extensions/<vendor>/<domain>-<capability>-connector/ with cinatra.kind: "connector" in its package.json. Registered with the extension registry via ConnectorExtensionTypeHandler (Phase 302/303; v4.6 Phase 306 widened the vendor scope). Today connectors are workspace-compiled: they ship in the build, are linked via pnpm workspace symlinks, and register at boot (src/lib/register-transport-connectors.ts for transport providers; src/lib/register-email-providers.ts / register-social-providers.ts / register-blog-providers.ts for the provider-neutral facades). The handler’s validate() enforces the kind-at-end naming + cinatra.kind declaration + (v4.6 Phase 306 F1) the generic-vendor policy boundary: any @<vendor>/<slug>-connector is admissible but gated by GENERIC_VENDOR_CONNECTOR_NAME_RE + cinatra.kind + package-name↔realpath (symlink-escape reject) + default admin visibility + static loader entries only. archive/restore are audit-only; install/update/uninstall throw ConnectorWorkspaceCompiledError — runtime/marketplace install is a future v2. The connector packages today: 10 transport (gmail, apify, apollo, google-calendar, github, linkedin, wordpress, drupal, youtube, media-feeds), 3 LLM-provider (openai, gemini, claudeLlmProviderAdapter not EmailConnector; the registry treats connector as a broad category), and 3 provider-neutral facadesemail-connector (v4.5 Phase 302/303), social-media-connector + blog-connector (v4.6 Phases 304–305). The first non-@cinatra-ai-scope connector is @ossflywheel/blog-connector (v4.6 Phase 306 — the F1 generic-vendor proof case; owns the ossflywheel Elementor logic). See docs/ai/blog-and-social-connectors.md.

Asset and entity packages are still not registered as separate extension kinds — their MCP primitives and UI surfaces ship as workspace packages compiled into the platform. The extension registry remains a deliberately small surface; adding a new kind still means writing and registering a new ExtensionTypeHandler (the connector handler is the reference example: packages/extensions/src/connector-handler.ts).

ObjectSyncAdapter is NOT a connector (Phase 302 rename). The object-store outbound-sync framework was historically called ObjectConnectorAdapter; it was renamed to ObjectSyncAdapter (interface, registry, cinatra.object_sync_adapter_configs table, adapter_id column) precisely to disambiguate it from these transport “connector” extension packages. They are orthogonal: a connector extension is an inbound/outbound integration to an external system; an ObjectSyncAdapter mirrors a Cinatra object out to an external CRM/CMS.


Extension packages are published as npm-style scoped packages. Their directory names equal their unscoped package names (1:1, kebab-case), and the kind goes at the end so the registry reads as a noun phrase (“email test delivery agent”, “assistant skills”) and sorts by functional domain. These rules apply to new and renamed extensions; legacy in-tree names that don’t conform are tracked in the drift-gate’s grandfatherList (advisory) and transitionalAllowlist (must shrink to zero).

StandardPackageName = "@" Scope "/" Slug "-" Kind
VendoredPackageName = "@" Scope "/" Slug // vendored carve-out
CatalogRef = ( StandardPackageName | VendoredPackageName ) ":" LocalId
Scope = "cinatra-ai" | <vendor-scope>
Kind = "agent" | "connector" | "artifact" | "skills"
Slug = kebab-case
LocalId = kebab-case

cinatra.kind in package.json (singular: "agent" | "connector" | "artifact" | "skill") is the authoritative signal for lifecycle, purge, and dispatch. The directory suffix is a strong hint and is validated by the drift-gate, but the manifest wins on disagreement.

The VendoredPackageName form (no -<kind> suffix) is allowed only for kind:"skill" bundles that mirror an upstream identity which does not follow Cinatra’s <slug>-<kind> convention — today that is @anthropics/skills. The drift-gate’s vendoredSkillScopeAllowlist and VENDORED_PACKAGE_NAME_ALLOWLIST enumerate the exact-name carve-outs; nothing else may use this shape.

KindAllowed scopes
Agent@cinatra-ai/
ConnectorAny scope (Phase 306 generic-vendor policy)
Artifact@cinatra-ai/
Skill@cinatra-ai/ OR an entry in the explicit vendored-scope allowlist (today: @anthropics)
KindDir patterncinatra.kindNaming ruleRequired filesHas src/?
Agent<slug>-agent/"agent"Role noun. Name the role the agent plays (author, planner, code-reviewer, security-reviewer, lint-policy, skill-recommender, email-outreach). Domain-prefixed role nouns are allowed (email-outreach-agent, blog-draft-writer-agent, context-selection-agent). Orchestrator-topology names are forbidden as suffix OR prefix: no -pipeline, -orchestrator, -handler, -child, -stage-N. Names where the role itself is “curator”, “coordinator”, or “router” are allowed (list-curator-agent). Grandfathered names that pre-date this rule (blog-pipeline-agent, the workflow-coupled email-*/blog-* families) are tracked by name in the drift-gate’s grandfatherList and removed as each is generalized-then-renamed.package.json, LICENSE, cinatra/oas.json, .cinatra-published.json, skills/<slug>/SKILL.mdNo
Connector (provider)<provider>-connector/"connector"<provider> is the third-party service slug. Has a workspace-mounted setup page: setup-page.tsx is ≤20 lines, impl named <Slug>ConnectorPageImpl; the route /connectors/<scope>/<slug>-connector/setup must match the directory name. Phase 306 widens scopes — any @<vendor>/<slug>-connector is admissible.package.json, src/index.ts, src/setup-page.tsx, src/<provider>-setup-impl.tsx, src/deps.ts, vitest.config.tsYes
Connector (facade)<domain>-connector/"connector"Provider-neutral abstraction over multiple provider connectors (email-connector, blog-connector, social-media-connector, mcp-client-registry-connector). NO setup-page (the route is per-provider). Carries a contract type + facade implementation + provider registry.package.json, src/index.ts, src/contract.ts, src/facade.ts, src/registry.tsYes
Artifact<content-type>-artifact/"artifact" + artifact:{...}Name MUST match @cinatra-ai/<slug>-artifact (validator enforced). <slug> describes the content type, not the producer or workflow. MUST NOT contain cinatra/oas.json. Matcher skill is conventionally <slug>-matcher, authoring skill <slug>-author. Cross-extension skill refs use the @<scope>/<pkg>:<skill-slug> colon syntax.package.json, src/index.tsYes (re-exports only)
Skill (bundle)<theme>-skills/ (plural dir, singular kind)"skill"<theme> groups by consumer: assistant-skills for the chat assistant, blog-skills for asset-blog generation, drupal-skills for the Drupal widget, skill-creator-skills for skill-authoring meta-skills. Individual skill slugs inside skills/<slug>/ are verb-noun (generate-blog-ideas, chat-campaign-creation).package.json, skills/<slug>/SKILL.md (one directory per skill)No

Reserved singleton — @cinatra-ai/context-selection-agent (formerly @cinatra-ai/context-agent, renamed in v5.7 / Phase 435). The agent owns the ContextSelector HITL renderer and the version-pinning that happens at context-selection time. Consumers reference it verbatim under cinatra.agentDependencies; only declared when the parent itself declares contextSlots (transitive declaration is forbidden). The pre-rename legacy renderer-id @cinatra-ai/context-agent:context-selector was removed early in the v5.7.1 close-out (originally scoped for v5.9) after telemetry proved zero paused runs referenced it — the renderer id is composed at render-time from the migrated template packageName and never persisted.

For kind:"skill" bundles that mirror an upstream identity not following Cinatra’s <slug>-<kind> convention (today: @anthropics/skills), the package name follows VendoredPackageName. The bundle is declared in the app-root package.json under cinatra.vendoredSkillBundles[] — this is the single source of truth for { packageName, destination, source: { type: "github", owner, repo, sha, url }, license, include[], skills.<slug>.matchWhen, skills.<slug>.level, skills.<slug>.patches[] }.

The lifecycle is dev fetches, prod consumes:

  • Development (CINATRA_RUNTIME_MODE === "development"): the pnpm postinstall runs scripts/vendor-anthropic-skills.mjs, which downloads the SHA-pinned tarball, extracts only the include[] paths into destination, applies the declarative patches[] to vendored SKILL.md files (fail-closed on missing anchors), and writes a synthesized package.json carrying cinatra.kind: "skill" and cinatra.vendoredFrom: { owner, repo, sha, url }. The fetcher self-manages a sentinel-bounded section of the repo’s root .gitignore so vendored content is never committed.
  • Production (any non-dev CINATRA_RUNTIME_MODE): the same package is consumed via extensions_install("@anthropics/skills@<semver>") from Cinatra’s Verdaccio. The CI release-prep job is the only allowed source of the publish tarball — the maintainer downloads the CI artifact, SHA256-verifies, and runs pnpm publish <downloaded-tarball>. SemVer is written into the generated package.json by CI; the maintainer never edits version locally and never runs the fetcher locally.

Consumer pinning (which agents are allowed to call which vendored skill) is declared per-skill in cinatra.vendoredSkillBundles[].skills.<slug>.matchWhen in the app-root package.json; the loader merges these into the catalog at registration time via a sidecar cinatra-matchers.json written by the fetcher.

  • Directory name == unscoped package name (1:1, kebab-case) for StandardPackageName; for VendoredPackageName, the directory matches the <slug> part (extensions/anthropics/skills/, not extensions/anthropics/@anthropics-skills/).
  • package.json.name and metadata.cinatra.packageName (when present) MUST match — drift breaks publish.
  • cinatra.kind is singular per the PluginType enum in @cinatra-ai/registries; "connector" and "artifact" are the extension-only additions.
  • Skill references across extensions use @<scope>/<pkg>:<skill-slug> colon syntax, not paths.
  • The prefix form (@cinatra-ai/agent-email-test-delivery, @cinatra-ai/skill-assistant) groups by package type rather than domain and is forbidden for new extensions.
  • The drift-gate at packages/extensions/src/__tests__/naming-conformance.test.ts enforces the naming, scope, and kind rules on every extensions/<scope>/<pkg>/package.json. It does NOT yet enforce the per-subtype required-files contract from the per-kind table above (provider connector setup-page, facade connector contract/facade/registry, etc.) — that gate is a follow-up. It carries three explicit lists: transitionalAllowlist (known violators with named fix-phases — must shrink to zero; the bookkeeping test fails when a fix lands but the allowlist entry isn’t removed), grandfatherList (legacy advisory-warn names deferred to v5.8+ generalize-then-rename), and oldNameDenylist (post-rename forbidden names; package-name-surface scope only, not repo-wide string grep).
  • @cinatra-ai/skill-creator — non-conformant directory (no -skills suffix) and partial vendor of a multi-skill upstream. Replaced by @anthropics/skills (Phase 434.1).
  • @cinatra-ai/auditor-agent — empty cinatra: {} block; gains apiVersion + kind: "agent" in Phase 432.
  • @cinatra-ai/email-test-delivery-agent — missing cinatra.kind; gains kind: "agent" in Phase 432.
  • @cinatra-ai/context-agent — fixed singleton renamed to @cinatra-ai/context-selection-agent (Phase 435); the legacy renderer-id alias was removed in the v5.7.1 close-out (telemetry-verified dead).
  • @cinatra-ai/claude-connector — misleading name (it’s an MCP-client-registry tracker for clients that connect TO Cinatra, not an outbound Claude integration). Renamed to @cinatra-ai/mcp-client-registry-connector (Phase 434.2).

For the per-kind specs see agent-packaging.md (agents), connector-route-extraction.md (provider-connector setup-page contract), artifacts-architecture.md (artifact validator gate), and context-slots.md (context-selection-agent integration). For per-kind authoring procedure see the .agents/skills/develop-{agent,connector,artifact,skill}/SKILL.md files.


Every installed extension carries an origin JSONB column on its primary row (agent_templates.origin or skill_packages.origin). This is the contract the rest of the lifecycle reads from:

{
"packageName": "@<scope>/<slug>", // npm-style scoped name as installed
"version": "1.4.2", // semver of the installed release
"scope": "@<scope>", // npm scope; derived from the publishing instance's namespace
"destinationId": "<opaque-id> | null",// null = public registry; otherwise an opaque key into extension_destinations
"registryUrl": "https://...", // self-contained registry URL used for install/update/migration
"visibility": "public" | "private",
"importedFrom": { // optional — present when imported from outside the marketplace flow
"source": "github" | "zip" | "chat",
"url": "https://...", // optional
"license": "Apache-2.0", // optional
"licenseAcknowledged": true, // optional
"updatePolicy": "manual" | "auto",
"lastSyncedAt": "2026-05-11T09:00:00Z" // optional
}
}

No secrets live in origin. The destinationId is an opaque pointer into the encrypted extension_destinations table, where per-field-AAD AES-256-GCM ciphertexts hold the read and publish tokens.

Visibility is a server-side contract, not UI styling. Public rows are universally visible. Private rows are visible only when their origin.scope matches the reading instance’s vendor scope. The filter runs at every server read path that touches extension tables — the marketplace screen, MCP extensions_search, the active/archived catalog readers, and any caller of listAgentPackages. UI-only filtering is forbidden.

The instance’s vendor scope comes from readInstanceIdentity() — it is the @<instanceNamespace> chosen during platform setup. An instance with no namespace cannot publish, and cannot see another instance’s private extensions.


Every workspace extension carries a extension_lifecycle_status enum ('active' | 'archived'):

  • active — installed and reachable. Agents run, skills match, UI mounts.
  • archived — installed-but-suspended. The DB rows that depend on the extension (agent_runs, agent_versions, agent_template_versions, agent_registry_entries, agent_forks) remain intact, but the extension itself is treated as unavailable. Re-activation is non-destructive.

There is no “draft” or “pending” state for an installed row — the install is either committed or not.


The extensionRegistry in @cinatra-ai/extensions dispatches every operation to the right type handler. The MCP primitives are admin-gated; the same surface is reachable from the UI.

extensions_install (admin-only). Routes to the handler for the package’s kind:

  • Agent handler. Resolves the dependency tree, fetches each package from its registry, extracts to a temp dir, validates the manifest and payload, upserts the agent_templates row, creates a new agent_template_versions snapshot, registers declared object types, installs declared package skills, and cleans up the temp dir.
  • Skill handler. Persists a skill_packages row and queues a skill-match recompute.

Both handlers write the origin record on their primary row.

extensions_update (admin-only). Has its own handler entry point, not delegated through install. Replaces the installed version with a newer one, applies anything the new version declares (new object types, new skill bundles), and bumps the persisted version inside origin. Existing run history and HITL state are preserved.

extensions_uninstall (admin-only). The dispatcher checks dependents and “has been used” before choosing a path:

ConditionResult
An active dependent extension still references this oneRefuses with ActiveDependentError, naming the dependent. Uninstall the dependent first.
An archived dependent still references this oneForces archive to preserve dependency closure.
No dependents, extension has been used (at least one persisted run exists)Forces archive so run history stays valid.
No dependents, extension unusedCalls the handler’s uninstall for a hard delete.

The extensionHasBeenUsed(ref) predicate is centralized so future handler kinds cannot drift on the definition of “used.”

extensions_archive (admin-only). Soft-uninstall. Flips extension_lifecycle_status to 'archived'. Idempotent.

extensions_restore (admin-only). Reverse of archive. Flips status back to 'active'. Idempotent.

extensions_force_delete (admin-only). The audited destructive escape hatch. Pre-cleans the five FK source tables that would otherwise block deletion (agent_runs, agent_versions, agent_template_versions, agent_registry_entries, agent_forks), writes a snapshot to extension_lifecycle_audit before deletion (actor, operation, package, the destroyed row, dangling references, the supplied reason), and only then removes the primary row. Requires confirmDestructive: true. The dispatcher owns this path — type handlers are not asked to implement it; the dispatcher invokes the handler’s uninstall after the audit is written and the FK sources are cleared. Scope: DB + on-disk dir for ONE version. It does NOT touch the Verdaccio registry — the package stays re-installable. For full removal everywhere, use purge.

registry-only — unpublish / delete (Phase 312)

Section titled “registry-only — unpublish / delete (Phase 312)”

extensions_registry_unpublish and extensions_registry_delete (admin-only). Pure Verdaccio package-name + version operations — kind-agnostic, with no DB / disk / extensionRegistry / deriveTypeId semantics (kind only matters for the installed-extension paths above). unpublish deprecates/yanks one version (history retained); delete hard-removes one version (deleted: true / notFound: true). These were generalized + renamed from the old agent-scoped agent_registry_unpublish / agent_registry_delete (Phase 312 — registry ops are not agent-specific, so they live under the extension-lifecycle namespace). They mutate the registry only — combine with uninstall/force_delete for installed state, or use purge for the all-in-one.

The only “gone everywhere” path: Verdaccio (all versions) + DB rows + on-disk dir + WayFlow reload, for any kind (agent / skill / connector). Split deliberately so destruction is never model-invokable (an MCP primitive is reachable by the chat LLM; a model-set confirm flag is theater):

  • extensions_purge MCP tool — DRY-RUN ONLY. Admin-gated (ADMIN_REQUIRED_TOOLS). Returns the blast radius (resolved kind, every published version, installed template id, active dependents that BLOCK) plus a digest. It never mutates; the handler can only call planExtensionPurge.
  • cinatra extensions purge <pkg> --confirm <pkg> --digest <d> --yes — the destructive path. A thin loopback fetch to admin+loopback+dev-mode POST /api/extensions/purge (same defense pattern as /api/skills/reset-repo); the human-origin path, not in the LLM tool surface.

Pipeline (packages/extensions/src/purge.ts, fail-closed, ordered): re-plan → production-host refusal (CINATRA_DB_PROD_HOSTS) → active-dependents hard block (3 edges: agentDependencies, compiled-plan childAgent.packageName, on-disk oas.json) → mandatory digest match (the dry-run handshake) → quarantine to data/extension-quarantine/<pkg>-<ts>/ (every version tarball + packument + agent_templates row snapshot; aborts before Verdaccio if any tarball can’t be snapshotted) → audit row (operation: "purge") before Verdaccio → unpublish all versions (per-version; on any partial failure STOPS before DB/disk, idempotently re-runnable; notFound = success) → TOCTOU re-plan → kind-branched DB/disk: force_delete for agent/skill, skip for connector (workspace-compiled — Verdaccio + audit + quarantine only; a connector with an unexpected DB row is refused, not half-purged). Kind is resolved authoritatively from the registry packument’s package.json cinatra.kind (getRegistryPackageKind) — never guessed; an unresolvable/unsupported kind refuses before any destruction. Quarantine is the recovery hedge: re-publish the tarballs until the operator deletes the quarantine dir, after which removal is irreversible.


The marketplace lives at Administration → Marketplace (/administration/marketplace). It reads from the configured Verdaccio registry, applies the server-side visibility filter, and renders a CTA per row.

CTAs render against the workspace’s current extension table — Install Now, Update Now, Installed, Restore — depending on whether the package is absent, present at an older version, present at the current version, or archived.

If the instance has no remote registry connected, the marketplace’s empty-state CTA points to the registry-configuration tab at /administration/environment?tab=registries.


Cinatra speaks to two registry kinds:

  • Private — the destination the publishing instance writes its own packages to. Typically a self-hosted Verdaccio. Credentials live in extension_destinations keyed by destinationId. Tokens are stored encrypted with per-field AAD bindings (destination.<id>.publish-token, destination.<id>.read-token).
  • Public — the multi-tenant shared registry every Cinatra instance can read from once connected. Connection is set up under Administration → Environment (/administration/environment?tab=registries); the instance submits a request, receives a single-use npm token after admin approval, and the token is stored in Nango — never in Cinatra’s own database.

The runtime resolves which registry handles a given operation through DeploymentRegistryConfig:

type DeploymentRegistryConfig = {
publicRegistryUrl: string;
publicReadToken: string;
publicPublishToken: string | null;
privateRegistryUrl: string | null;
privateReadToken: string | null;
privatePublishToken: string | null;
privateDestinationConfigured: boolean;
privateDestinationId: string | null;
routingMode: "scope-based" | "shared-acl";
};

routingMode selects the topology adapter:

  • scope-based — emits --@<scope>:registry=<url> so each scope routes to its own registry. Used when the instance’s private destination uses a distinct npm scope.
  • shared-acl — emits plain --registry=<url> so a single registry vhost serves multiple instances with ACL-based isolation. Used when the private destination shares the vhost with the public registry.

routingMode is required; an unset value is treated as a configuration error rather than a fallback.

resolvePublishDestination("private" | "public") and resolveInstallEnvironment(extensionId) are the canonical entry points. Direct callers of the lower-level Verdaccio config helpers are blocked by a regression test that scans for the old call sites.


Admin-only. The flow:

  1. Author the extension as a versioned package (TypeScript workspace package or an OAS Flow agent directory under agents/).
  2. Bump the package version.
  3. Pick a publish destination — private or public. Private is the default.
  4. Run the publish action. The platform packs the tarball, resolves credentials from extension_destinations by destinationId, and pushes through the topology adapter for the chosen routingMode.
  5. The destination’s extension_lifecycle_status becomes active on the publisher’s row; the package is now installable elsewhere through the normal marketplace flow.

For OAS Flow agents specifically, agent_source_publish runs the same deterministic review gate that agent_source_review surfaces (runDeterministicReview(...)) before pushing — a publishable agent must pass the gate.


promoteExtensionToPublicAction(extensionId) is the admin-gated path. It:

  1. Requires requireAdminSession().
  2. Refuses if the extension is already public (origin.visibility === "public").
  3. Republishes the package to the public destination using its current version.
  4. Updates origin.visibility to "public".
  5. Fire-and-forget writes a promote operation row to audit_events (with resourceType: "extension_registry"). It does not write to extension_lifecycle_audit — that table is reserved for destructive operations.

Promotion is one-way in v1. Public-to-private demotion is intentionally blocked; the demotion button is rendered disabled-but-visible with a locked tooltip ("Demotion not supported in v1 — contact ops"). The disabled-visible pattern is preferred over hidden so the limit is discoverable. Do not stub a half-built demotion flow.