Skip to content

Kinds & extensions — what behaviour DNA knows about

The kernel knows no Kinds. Every unit of identity and composition arrives through these ports, which is why adding a Kind never touches the core.

Generated from the source

Names, signatures and docstrings are parsed out of packages/sdk-py/dna by scripts/gen_ports_docs.py. The prose around each contract is hand-written in scripts/ports_prose.py, and the generator fails if a port has none — so a new port cannot ship undocumented.

Extension

dna.kernel.protocols.Extension · @runtime_checkable · extension point

The unit of packaging. One register() call contributes everything your extension adds; 21 ship in-tree, declared as entry points.

From the source

Registers kinds, readers, and writers on the Kernel.

kernel.load(ext) fail-loud validates the whole contract before calling register(): name must be a non-empty str, version a str, register callable (ExtensionLoadError otherwise). register() receives the registration-time host slice — see :class:ExtensionHost for the exact vocabulary.

Optional capabilities (feature-tested, NOT required Protocol members so legacy extensions predating Phase 0 keep working):

  • :class:TemplateProviderdef templates(self) -> list[Template]. When present, Kernel.list_templates() aggregates entries from every loaded extension so UIs (Tauri Studio, CLI) can offer scaffold() for any extension-shipped file tree. See dna.kernel.compose.templates.Template for the payload shape.
  • :class:ManifestActivatordef activate_manifest(self, mi, kernel). When present, Kernel.run_manifest_activators(mi) calls it so the extension can turn its OWN declared instances into runtime behaviour (a Hook onto the HookRegistry, a SafetyPolicy into a masking middleware). This is where the schema of an extension's Kind gets read; it must not be read in the kernel.

The contract

Member Signature What it must do
register def register(self, kernel: 'ExtensionHost') -> None

Swap it when — You are shipping more than one Kind, or any Kind plus its reader/writer, or you want your Kinds discovered by installation rather than by import.

The minimum that worksregister(host). Declare it under the dna.extensions entry-point group and installing your package is all the wiring there is.

What it lights up — Auto-discovery at Kernel.auto(). The kernel validates every registration at boot and fails loud on conflicts — duplicate (apiVersion, kind), duplicate aliases, a Reader missing a required method — so a broken extension stops boot rather than half-registering.

How you prove it — Boot Kernel.auto() and assert your Kinds are in kernel.kind_ports(). The 21 shipped extensions are the worked examples.

Shipped implementations — 21 in-tree extensions — helix, agentskills, soulspec, agentsmd, guardrails, kinddef, hooks, safety, recognizer, evidence, audit, collab, sdlc, federation, testkit, tenant, lesson, research, doc, modelreg — each declared under the dna.extensions entry-point group rather than subclassing anything

ExtensionHost

dna.kernel.protocols.ExtensionHost · @runtime_checkable · handed to you

The nine things you may do inside Extension.register(): register a Kind (from a class or a descriptor), a reader, a writer, a hook, a veto, a tool, a composition profile.

From the source

The registration-time surface the Kernel offers to Extension.register().

This is the explicit contract of what an extension may call while it is being loaded (s-dna-extension-host-contract). It is a narrow slice of the Kernel — the registration vocabulary — NOT the whole Kernel API. Deriving it from actual usage across every builtin extension keeps it honest:

======================== ================================================= Member What it registers ======================== ================================================= kind(kp) a KindPort (identity + composition of a Kind) kind_from_descriptor a record Kind from a kinds/*.kind.yaml descriptor dict (F3 — Kinds as data). Pair it with dna.kernel.source.descriptor_loader. load_descriptors(package) to read the package data files. reader(r) a ReaderPort (detect/scan a bundle format) writer(w) a WriterPort (write a bundle format) on(hook, fn) an event subscriber (e.g. post_save) on_veto(hook, fn) a veto listener (e.g. pre_save write guards — raising vetoes the operation) tool(td) a ToolDefinition (tool metadata) composition_profile a CompositionProfile (orchestrator kind wiring) hooks the HookRegistry itself, for advanced listener management (kernel.hooks.on_veto(..., key=)) ======================== =================================================

The real Kernel satisfies this Protocol structurally (guarded by tests/test_extension_host_contract.py). TS twin: ExtensionHost in src/kernel/protocols.ts.

You do not implement this — the kernel does. The kernel implements it and passes it into your register(). You call these methods; you never satisfy this Protocol. It is on this page because it is the menu — everything an extension is allowed to contribute is in the table below, and nothing else is.

The contract

Member Signature What it must do
hooks def hooks(self) -> Any
kind def kind(self, k: KindPort) -> None
kind_from_descriptor def kind_from_descriptor(self, raw: dict[str, Any]) -> KindPort
reader def reader(self, r: ReaderPort) -> None
writer def writer(self, w: WriterPort) -> None
on def on(self, hook: str, fn: Any) -> None
on_veto def on_veto(self, hook: str, fn: Any, *, priority: int=..., key: str | None=...) -> None
tool def tool(self, td: ToolDefinition) -> None
composition_profile def composition_profile(self, profile: Any) -> None

KindPort

dna.kernel.protocols.KindPort · @runtime_checkable · extension point

Identity, schema and composition role for one Kind. The port that makes the kernel knows no Kinds true.

From the source

WHO — identity + composition role.

This runtime_checkable Protocol lists ONLY the core contract every Kind must provide — it is exactly what the H1 registration gate (kernel.kindisinstance(k, KindPort)) enforces.

The optional presentation/UX surface (docs, ui_schema, graph_style, ascii_icon, display_label, presentation, description_fallback_field, visible_in_backend, preview(), graph_meta()) lives on the separate KindPresentation capability Protocol below — declared there so it is typed + documented, but NEVER required by the isinstance check (s-dna-kindport-descriptor-schema).

.. warning:: do NOT add optional members to THIS Protocol body. runtime_checkable isinstance checks member PRESENCE — a new member here silently breaks registration of every third-party Kind that doesn't declare it (the is_runtime_artifact precedent — see test_port_contract.py). Optional surface goes on KindPresentation (or a new capability Protocol) instead.

The contract

Member Signature What it must do
dep_filters def dep_filters(self) -> dict[str, str] | None
dependencies def dependencies(self) -> dict[str, str] | None Which spec fields reference other kinds by alias.
schema def schema(self) -> dict[str, Any] | None JSON Schema for this kind's spec.
get_default_agent_name def get_default_agent_name(self, doc: Any) -> str | None
get_layer_policies def get_layer_policies(self, doc: Any) -> dict[str, dict[str, LayerPolicy]] | None
parse def parse(self, raw: dict[str, Any]) -> Any
describe def describe(self, doc: Any) -> str | None
summary def summary(self, doc: Any) -> dict[str, Any] | None
prompt_template def prompt_template(self) -> str | None

Swap it when — Only when your Kind needs custom behaviour — a bespoke bundle format, a typed parse step, a composition rule. A record-shaped Kind with none of that needs no class at all: write a *.kind.yaml descriptor and register it with kind_from_descriptor(). Reach for a class second, not first.

The minimum that workskind, alias, api_version, plane and schema(). Everything else has a default.

What it lights up — Your Kind across every face at once — CLI, REST, MCP, the generic instance tools — because those faces are generic over the registry. It also enrols your Kind in the generated Kinds reference, so registering is what makes it documented.

How you prove it — Register it and let scripts/gen_kinds_docs.py and scripts/docs_coverage_guard.py run — the first proves the kernel sees it, the second fails the build until prose exists for it.

Shipped implementationsKindBase (dna.kernel.kinds.base) — the base every built-in Kind subclasses; third-party Kinds may satisfy the Protocol structurally instead

KindPresentation

dna.kernel.protocols.KindPresentation · typing-only (not @runtime_checkable) · extension point

An optional slice of a Kind: the short preview a list renders, and the metadata a graph view needs.

From the source

Optional presentation/UX capability of a Kind (typing-only).

Every member here is OPTIONAL at runtime: a Kind that provides none of them is still a perfectly valid KindPort (the H1 gate never requires them). This Protocol exists so the ~9 attrs/methods that used to live only in docstrings + hasattr duck-typing have an explicit, typed home (s-dna-kindport-descriptor-schema).

Deliberately NOT @runtime_checkable and NOT part of KindPort: runtime_checkable Protocols check member PRESENCE, so folding these into KindPort would make isinstance (the H1 registration gate) reject every minimal third-party Kind — exactly the breakage the is_runtime_artifact addition caused once (see test_port_contract.py).

Conventions:

  • KindBase provides defaults for all ATTRIBUTE members (None), so subclasses opt in field-by-field.
  • preview/graph_meta have no KindBase default — ABSENCE is meaningful (consumers fall back to the generic renderer).
  • Consumers read via typed access with a default — getattr(kp, "ascii_icon", None) / fn = getattr(kp, "preview", None) — never hasattr.
  • Tracked in tests/golden-fixtures/port-surface.json (the KindPresentation port).

Members:

  • docs — prose explanation of what this kind IS at the concept level. Surfaced by the harness describe_kind tool. When an extension ships a DOCS.md next to its package, the kernel's _load_kind_docs loader overrides this attribute at load time.
  • description_fallback_field — spec field to derive metadata.description from when none was declared. See Kernel._fill_derived_description.
  • ui_schema — per-field UI hints for Studio form rendering, keyed by spec field name. Each entry may declare widget (text | textarea | markdown | markdown-toc | code | select | checkbox | list-markdown | tags | readonly), label, help, language (for code), height (px), order. When absent, consumers infer the widget from the value type. See docs/KIND-UI-HINTS.md for the full contract.
  • graph_style{"fill": "#F97316", "stroke": "#EA580C", "text_color": "#fff"} colors for mermaid/graph visualizations.
  • ascii_icon — single emoji/char for ASCII tree views.
  • display_label — human-friendly plural label (e.g. "Agents").
  • presentation — how this Kind's DATA reads, declared ONCE for every surface: an ordered field list, each entry carrying a human label and an optional semantic role (identifier/title/status/ owner/parent/… — a closed vocabulary), plus the fields to keep hidden. Deliberately NOT a layout — it says what a field MEANS, and each surface decides what that becomes on it (a table column, a state line, a badge). ui_schema is its sibling and NOT its twin: that one hints how a human EDITS a field (widget, help, height); this one says what the value means when a human READS it. Normalized + validated by dna.kernel.kinds.presentation (presentation_of and the presentation_wire envelope, which composes it with display_label and ascii_icon). A TENANT Kind declares it in the very same words, as KindDefinition.spec.presentation.
  • visible_in_backend — explicit backend-visibility override; None falls back to default_visible_in_backend(storage) (see resolve_visible_in_backend).
  • preview(doc) — renderable blocks for the Studio's preview pane; absent (or None result) → generic_spec_dump.
  • graph_meta(doc) — per-doc annotations for graph rendering and health checks (e.g. Guardrail returns severity/scope/rules).

The contract

Member Signature What it must do
preview def preview(self, doc: Any) -> 'list[PreviewBlock] | None'
graph_meta def graph_meta(self, doc: Any) -> dict[str, Any] | None

Swap it when — Your Kind shows up in a UI and the default rendering is unhelpful.

The minimum that works — Either preview or graph_meta; both are optional.

What it lights up — Richer rendering in the console and graph views. Omitted, callers get the generic presentation — a real degradation, not a failure.

How you prove it — Covered by your Kind's own tests; there is no separate battery.

Shipped implementations — none in-tree. This port has no reference adapter yet: you would be writing the first one.

KindRelations

dna.kernel.protocols.KindRelations · typing-only (not @runtime_checkable) · extension point

The declared relations of a Kind — attribute-shaped, which is why it has no methods.

From the source

Optional relations capability of a Kind (typing-only).

A sibling of :class:KindPresentation and deliberately not a member of it: presentation says how a Kind's data READS on a surface, relations say what the Kind POINTS AT — a statement about the model, true whether or not anything is being rendered. Folding one into the other would make a Kind that declares relations also claim a rendering opinion it never had.

Same two constraints as KindPresentation, for the same reasons: NOT @runtime_checkable and NOT part of KindPort, because runtime_checkable checks member PRESENCE and the H1 registration gate would then reject every minimal third-party Kind — the breakage the is_runtime_artifact addition caused once.

  • relations{relation name: Relation}, where a relation's NAME is the spec field holding its value. Four keys and no more: to (the target Kind, a list for a polymorphic one, or * when the target travels in the value), cardinality (one/many, declared and never inferred from type: array), inverse_of (the relation name on the target that is this one's other half) and by (how the value addresses the target — name by default, and the ONLY addressing the kernel resolves and therefore validates). Normalized + validated by dna.kernel.kinds.relations (relations_of). A TENANT Kind declares it in the very same words, as KindDefinition.spec.relations.

No methods: this Protocol is satisfied by attributes, not calls (see the source docstring above).

Swap it when — Your Kind references other instances and you want those references in the derived reference graph.

The minimum that works — The declared relation attributes; see the source docstring above.

What it lights up — Edges in the reference graph — if the active source records edges. This is the sharpest instance of the catalogue's central rule: on a store that declares edge_graph=False, the graph face answers unsupported (REST 501) and not an empty list, because [] reads as nothing points at this instance, and that is a claim only a store which actually keeps edges may make. Declaring relations on a filesystem-backed deployment is therefore not wrong — it is simply unanswerable, and the face says so.

How you prove itdna graph against a Postgres-backed source; tests/test_graph_traversal.py is the in-tree reference.

Shipped implementations — none in-tree. This port has no reference adapter yet: you would be writing the first one.

ManifestActivator

dna.kernel.protocols.ManifestActivator · @runtime_checkable · extension point

An optional extra capability of an Extension. Registering a Kind says what an instance of it IS; this says what it DOES once a scope is resolved — a Hook instance onto the kernel's HookRegistry, a SafetyPolicy into a masking middleware on pre_build_prompt.

From the source

Optional Extension capability — turn declared instances into BEHAVIOUR.

Registering a Kind says what an instance of it is. Some Kinds also have to do something once a scope is resolved: a Hook instance has to end up on the kernel's HookRegistry, a SafetyPolicy with scope: input has to end up as a masking middleware on pre_build_prompt. That second half is what this capability names.

Why it exists (i-112, board dna). Both of those lived in ManifestInstance.apply_hooks — in the KERNEL — where they read Hook.spec field by field (target/type/action/fields/ body, exec()-ing the body) and SafetyPolicy.spec (scope/action/rules, building a ScannerPipeline). The extensions that register those two Kinds did nothing but kernel.kind(...): the extension declared the TYPE and the kernel implemented the BEHAVIOUR, which is the dependency pointing the wrong way — the same defect i-109 measured for the Typed* models one axis over.

i-109's argument is why this is a Protocol and not a trait: a trait would have swapped two strings and left every field read in the kernel, making the boundary LOOK fixed. The knowledge that had to move is the SCHEMA, so the code that reads it is what moves.

Kept OFF the Extension Protocol body, exactly like :class:TemplateProvider, so extensions that do not activate anything (most of them) keep satisfying Extension unchanged. Kernel.run_manifest_activators(mi) feature-tests each loaded extension and calls it; ManifestInstance.apply_hooks() is the public entry point that asks the kernel to do so, and it names no Kind at all.

The kernel is passed explicitly rather than fished out of the manifest, because an activator's whole job is to register something ON the kernel — a capability that had to reach through a private attribute to do its work would be an inversion in name only.

The contract

Member Signature What it must do
activate_manifest def activate_manifest(self, mi: Any, kernel: Any) -> None

Swap it when — Instances of your Kind have to do something at resolve time, not just be readable. If reading them is enough, you do not need this.

The minimum that worksactivate_manifest(mi, kernel).

What it lights upManifestInstance.apply_hooks() — which despite the name activates every extension, not only hooks. Detected by feature test, so omitting it is invisible and harmless; an activator that raises is warned about and skipped, never fatal.

How you prove itHookExtension.activate_manifest() (dna.extensions.hooks) is the reference: it reads Hook.spec field by field and calls kernel.hooks.use / .on. That code used to live in the kernel, which is why this port exists — an extension registers the Kind, so the extension owns the code that reads its schema (i-112).

Shipped implementationsHookExtension (dna.extensions.hooks) — registers declared Hooks on the HookRegistry; SafetyPolicyExtension (dna.extensions.safety) — installs input-side PII masking from a declared SafetyPolicy

TemplateProvider

dna.kernel.protocols.TemplateProvider · @runtime_checkable · extension point

An optional extra capability of an Extension: ship starter files.

From the source

Optional Extension capability — ships scaffold file trees.

Kept OFF the Extension Protocol body so legacy extensions that predate Phase 0 keep satisfying Extension without modification. Kernel.list_templates() feature-tests each loaded extension (isinstance(ext, TemplateProvider) — or the historical hasattr(ext, "templates")) and aggregates the entries so UIs (Studio, CLI) can offer scaffold() for any extension-shipped file tree. See dna.kernel.compose.templates.Template.

The contract

Member Signature What it must do
templates def templates(self) -> list['Template']

Swap it when — Your extension has a dna new-style starting point worth shipping.

The minimum that workstemplates().

What it lights up — Your templates in the scaffolding commands. Detected by feature test, so omitting it is invisible and harmless.

How you prove itSafetyPolicyExtension.templates() (dna.extensions.safety) is the only shipped implementation and the reference.

Shipped implementationsSafetyPolicyExtension (dna.extensions.safety) — satisfies it structurally, via an optional templates() method

ToolPort

dna.kernel.protocols.ToolPort · @runtime_checkable · extension point

A callable exposed to agents, wrapping a LangChain StructuredTool so framework compatibility is preserved while DNA adds discovery and policy on top.

From the source

An invocable tool exposed to agents. The underlying callable is a langchain StructuredTool (preserves framework compatibility); this port adds DNA discovery metadata (group, hitl, scope).

The contract

Member Signature What it must do
get_callable def get_callable(self) -> Any Return the underlying langchain StructuredTool (or function).

Swap it when — Rarely as a class. ToolDefinition is the concrete implementation and almost everyone should instantiate it rather than write a second one — the pluralism here is in the tool instances, not in implementations of the port.

The minimum that worksget_callable().

What it lights up — The tool over the MCP face and in emitted agents. Note that the tool definition is data — see Tools as data — so the usual answer is a declared tool, not a new class.

How you prove it — Exercise it through dna mcp serve and the list_tools face.

Shipped implementations — none in-tree. This port has no reference adapter yet: you would be writing the first one.