Skip to content

Source Package Architecture

Each reusable source package should define:

  • a clear package purpose
  • an instance-based data model when repeated runs or named configurations matter
  • explicit execution state and result storage
  • public MCP primitives for CRUD, execution, and results
  • generic creation and detail UIs that do not embed business-specific configuration

A composed source should own:

  • scenario-specific user input and configuration UI
  • downstream instance creation
  • orchestration sequencing across reusable sub-packages
  • run history and progress mapping
  • orchestration-specific legacy SKILL.md behavior

A composed source should not reimplement the generic execution logic moved into sub-packages.

Use deterministic code for:

  • fetches
  • parsing
  • redirect checks
  • validation HTTP requests
  • structured third-party APIs
  • persistence
  • aggregation

Use LLM orchestration for:

  • extraction from raw fetched content when the user provides flexible instructions
  • plan generation across a dataset
  • reasoning-heavy per-item research
  • schema-guided transformation that cannot be expressed safely as deterministic parsing alone
  • default package skills live under packages/*/skills/*/SKILL.md
  • repo-wide refactor or architecture playbooks can live under skills/*/SKILL.md
  • legacy skill preservation is mandatory during package splits
  • instance-specific custom skills may be stored on instance records and, when expected by the architecture, persisted to the skills store

Every agent package declares its input and output object types via AgentIOSpec (Phase 72). This is separate from the object type registry — registration describes the type’s shape and lifecycle; AgentIOSpec describes what flows into and out of an agent execution.

Plugin typeWhereHow
AgentPluginDefinitionplugin/definition.ts inline fieldioSpec: { ... } satisfies AgentIOSpec
CampaignPluginDefinitionstandalone export in plugin/definition.tsexport const xIoSpec: AgentIOSpec = { ... }
Content / other plain objectsstandalone exportsame as campaign
Virtual (builder-compiled)emitted by compiler SKILL.md LLMparsed by agentIOSpecSchema.safeParse() in compiler.ts

All type strings in input[] and output[] must exactly match a registered object type ID from the Phase 70 registry (@cinatra/<pkg>:<local-id>). Non-matching IDs silently cause canCompose() to return false — there is no runtime error.

Code-based agents seed their ioSpec to agent_templates.io_spec at startup via seedCodeBasedAgentIoSpec from /agents. The function is idempotent (guards on io_spec IS NULL). Only call it from packages that have rows in agent_templates (i.e., packages with AgentPluginDefinition). Campaign and content plugins have no agent_templates rows — do not call seedCodeBasedAgentIoSpec from those modules.

import { canCompose } from "@cinatra/object-types";
canCompose(producer, consumer) // true if any producer.output[].type ∈ consumer.input[].type

Returns false when either array is empty — source agents with input: [] cannot be checked as consumers.

When a split is already mostly complete:

  • do not re-plan completed architecture
  • isolate the remaining defect
  • treat UI modal-session bugs separately from MCP, orchestration, and skill wiring if those are already complete

BASE / renderer split for objectTypeRegistry.register

Section titled “BASE / renderer split for objectTypeRegistry.register”

Added Phase 290.4 (PR #286) after the lists package’s createListAction server action started 500ing with runDeterministicLlmTask requires actorContext — root cause was that @cinatra/lists:list was registered only at boot via createListModule() in src/lib/mcp-server.ts, and the Next.js code-split chunk for the action never reached that module.

When wiring objectTypeRegistry.register({...}) into a code path that load-time-couples with server actions or other server-only entry points, split the definition into two layers:

Lives next to the adapter (e.g. packages/<pkg>/src/integration/<entity>-object-type-definition.ts). Exports the schema + lifecycle + identityKey + stub renderers (() => null). No @/components/ui/* imports, no React import beyond type level.

The adapter file (packages/<pkg>/src/integration/<entity>-adapters.ts) imports the base and registers it as a module-top side effect:

import { objectTypeRegistry } from "@cinatra-ai/objects";
import { LIST_OBJECT_TYPE_DEFINITION_BASE } from "./list-object-type-definition";
objectTypeRegistry.register(LIST_OBJECT_TYPE_DEFINITION_BASE);

Any code path that imports the adapter — including every server-action chunk in the package — now lands the schema in the registry. classifyObject(rawData, typeHint) short-circuits to the registered entry before reaching the LLM classifier, so the ALS-frame requirement never fires.

The renderers field on ObjectTypeDefinition is required, so the base uses no-op stubs. They never reach the UI in practice — if they ever do, the boot path is misconfigured.

Lives in packages/<pkg>/src/integration/register-object-types.ts (the existing entrypoint). Imports both the BASE and the real React renderers, then re-registers via spread:

import { objectTypeRegistry } from "@cinatra-ai/objects";
import { ListListRow, ListCard, ListDetail } from "./renderers";
import { LIST_OBJECT_TYPE_DEFINITION_BASE } from "./list-object-type-definition";
export function registerListObjectTypes(): void {
objectTypeRegistry.register({
...LIST_OBJECT_TYPE_DEFINITION_BASE,
renderers: { listRow: ListListRow, card: ListCard, detail: ListDetail },
});
}

Called from createXModule() and from src/lib/register-all-object-types.ts. Map#set semantics on objectTypeRegistry.register mean this second write overwrites the first — the renderer-bearing entry wins for any UI surface that mounts after boot.

Putting the renderer-bearing registerXObjectTypes() directly at the top of the adapter (the obvious one-line fix) drags ./renderers.tsx@/components/ui/* into the server-only chunk. Every shadcn component carries "use client". Next.js / Turbopack either bloats the action bundle or fails RSC bundle-boundary checks. Codex caught this exact regression in Phase 290.4 r2 before merge.

Lock the wire with a hermetic vitest using vi.hoisted({ fakeRegistry }) plus vi.mock("@cinatra-ai/objects"):

  • Adapter import registers stubs (assert entry.renderers.listRow({}) === null).
  • MCP handlers import triggers the same registration (covers the second entry point).
  • Boot path registers renderers distinct from the stubs (assert strict identity against BASE.renderers.*).

Reference implementation: packages/lists/src/__tests__/object-type-registration.test.ts.