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:
TemplateProvider—def templates(self) -> list[Template]. When present,Kernel.list_templates()aggregates entries from every loaded extension so UIs (Tauri Studio, CLI) can offerscaffold()for any extension-shipped file tree. Seedna.kernel.compose.templates.Templatefor the payload shape. - :class:
ManifestActivator—def 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 (aHookonto the HookRegistry, aSafetyPolicyinto 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 works — register(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.kind → isinstance(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 works — kind, 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 implementations — KindBase (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:
KindBaseprovides defaults for all ATTRIBUTE members (None), so subclasses opt in field-by-field.preview/graph_metahave 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)— neverhasattr. - Tracked in
tests/golden-fixtures/port-surface.json(theKindPresentationport).
Members:
docs— prose explanation of what this kind IS at the concept level. Surfaced by the harnessdescribe_kindtool. When an extension ships aDOCS.mdnext to its package, the kernel's_load_kind_docsloader overrides this attribute at load time.description_fallback_field— spec field to derive metadata.description from when none was declared. SeeKernel._fill_derived_description.ui_schema— per-field UI hints for Studio form rendering, keyed by spec field name. Each entry may declarewidget(text | textarea | markdown | markdown-toc | code | select | checkbox | list-markdown | tags | readonly),label,help,language(forcode),height(px),order. When absent, consumers infer the widget from the value type. Seedocs/KIND-UI-HINTS.mdfor 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 humanlabeland an optional semanticrole(identifier/title/status/owner/parent/… — a closed vocabulary), plus the fields to keephidden. 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_schemais 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 bydna.kernel.kinds.presentation(presentation_ofand thepresentation_wireenvelope, which composes it withdisplay_labelandascii_icon). A TENANT Kind declares it in the very same words, asKindDefinition.spec.presentation.visible_in_backend— explicit backend-visibility override;Nonefalls back todefault_visible_in_backend(storage)(seeresolve_visible_in_backend).preview(doc)— renderable blocks for the Studio's preview pane; absent (orNoneresult) →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 fromtype: array),inverse_of(the relation name on the target that is this one's other half) andby(how the value addresses the target —nameby default, and the ONLY addressing the kernel resolves and therefore validates). Normalized + validated bydna.kernel.kinds.relations(relations_of). A TENANT Kind declares it in the very same words, asKindDefinition.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 it — dna 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 works — activate_manifest(mi, kernel).
What it lights up — ManifestInstance.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 it — HookExtension.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 implementations — HookExtension (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 works — templates().
What it lights up — Your templates in the scaffolding commands. Detected by feature test, so omitting it is invisible and harmless.
How you prove it — SafetyPolicyExtension.templates() (dna.extensions.safety) is the only shipped implementation and the reference.
Shipped implementations — SafetyPolicyExtension (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 works — get_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.