Skip to content

Runtime & threads — serving a live copilot

Emitting produces a file. These ports produce a running agent, and the conversation state that outlives a single request. The thread ports are split along a real fault line — what the framework knows versus what only the host knows — and the split is the whole design.

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.

AGUIApp

dna.runtime.port.AGUIApp · @runtime_checkable · extension point

The handle a RuntimePort hands back: a framework-agnostic AG-UI app the host mounts on a path.

From the source

The handle a :class:RuntimePort returns — a framework-agnostic AG-UI app the host can mount and, where the backend is LangGraph-shaped, rehydrate against.

Optional member — thread_store. An adapter MAY expose the read half of :mod:dna.runtime.thread_store over its own backend (the LangGraph one does; MAF has no thread store yet and says so with None). It is deliberately NOT a required member of this Protocol: making it one would flip isinstance for every adapter written before it existed. Probe it with getattr(app, "thread_store", None).

The contract

Member Signature What it must do
attach def attach(self, app: Any, path: str='/agui') -> None Mount this app's native AG-UI endpoint onto app at path.

Swap it when — Whenever you implement RuntimePort — you have to return one of these.

The minimum that worksattach(app, path).

What it lights up — The copilot being reachable over HTTP at all. Note the standing house rule: AG-UI has official client libraries, and the wire protocol is not something to re-derive from its specification. Your job is mounting, not re-implementing the protocol.

How you prove it — Drive it with an official AG-UI client, not with a hand-rolled one.

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

AgentHarnessPort

dna.runtime.harness.AgentHarnessPort · typing-only (not @runtime_checkable) · extension point

A runtime adapter serves an AG-UI app; a consumer harness starts one portable run while the provider SDK keeps ownership of its model and tool loop. DNA owns the request, lifecycle events, cancellation and session metadata around that loop.

The contract

Member Signature What it must do
start async def start(self, definition: ResolvedAgent, request: RunRequest) -> RunHandle

Swap it when — You want dna run or another DNA consumer to execute through a provider SDK that has no shipped harness.

The minimum that works — A provider id and start(definition, request) returning a RunHandle. Register it with register_harness(...).

What it lights upRuntimeBinding.provider: <yours> and dna run --provider <yours>. An unregistered provider fails with the available harness ids instead of silently selecting another execution path.

How you prove it — Drive the adapter with its provider SDK fake and assert lifecycle order, streaming, cancellation and session resume. tests/runtime/test_github_copilot_harness.py is the reference.

Shipped implementationsGitHubCopilotHarness (dna.runtime.adapters.github_copilot_harness) — provider github-copilot, needs the CLI [harness] extra

RuntimePort

dna.runtime.port.RuntimePort · @runtime_checkable · extension point

An emitter writes a file; a runtime adapter builds a live app. Two ship — LangChain and Microsoft Agent Framework — behind their own extras, and each import is independently guarded so a missing extra removes one backend instead of breaking the registry.

From the source

A framework adapter — builds a live :class:AGUIApp from a neutral :class:~dna.emit.EmitContext + :class:RuntimeHooks. Implement this + call :func:register_runtime to add a backend.

target Stable id used by serving.framework (e.g. "langchain", "maf") to select this backend.

:meth:build The construction — reads the neutral context and hooks, wires the four DNA disciplines into the framework's native mechanism, and returns an :class:AGUIApp. Performs NO network I/O (the lazy-MCP invariant holds through every adapter).

The contract

Member Signature What it must do
build async def build(self, ctx: Any, hooks: RuntimeHooks) -> AGUIApp Build a live :class:AGUIApp from ctx + hooks.

Swap it when — You want to serve DNA agents on a framework neither shipped adapter covers.

The minimum that worksbuild(ctx, hooks) returning an AGUIApp, and a target id. Register with register_runtime(...).

What it lights upserving.framework: <yours> becoming a valid declaration, and the copilot serving on your backend. An unregistered target fails with the list of available ones — which will be short if an extra is missing, so check what is installed before concluding a target is unsupported.

How you prove it — Serve a real agent and drive it over AG-UI. There is no unit-level battery here; dna.runtime.adapters.langchain_rt is the reference to read.

Shipped implementationsLangChainRuntime (dna.runtime.adapters.langchain_rt) — target langchain, needs the [runtime] extra; MafRuntime (dna.runtime.adapters.maf_rt) — target maf, needs the [maf] extra

ThreadIndexPort

dna.runtime.thread_store.ThreadIndexPort · @runtime_checkable · extension point

The half the framework cannot answer. A checkpointer knows messages; it does not know users. This index is the only place ownership is written down.

From the source

De QUEM é a conversa, e quais são as de alguém. É a metade que o framework não sabe responder e que o host guarda num índice ao lado — o único lugar onde há escrita, e ela é por turno, barata e sem transcript.

The contract

Member Signature What it must do
index_thread async def index_thread(self, *, owner: str | None, thread_id: str | None, workspace: str | None=None, tenant: str | None=None, messages: Sequence[Any]=(), copilot: str | None=None, surface: str | None=None) -> ThreadRef | None Registra/atualiza a conversa e ENFORCE a posse.
fetch_threads async def fetch_threads(self, *, owner: str, workspace: str | None=None, copilot: str | None=None, limit: int=50) -> Sequence[ThreadRef] As conversas de owner, mais recentes primeiro por atividade real. Os filtros COMPÕEM com o dono, nunca o substituem — sem owner a resposta é vazia, jamais "todas".
thread_owner async def thread_owner(self, thread_id: str) -> str | None O dono indexado de thread_id, ou None se desconhecido. A metade de leitura da guarda de posse — combine com :func:can_read_thread, não com uma segunda regra.

Swap it when — Always, if conversations belong to people. Ownership is an authorization input, not a display convenience.

The minimum that worksindex_thread, fetch_threads, thread_owner.

What it lights up — "My conversations" lists, and the ownership check that stops one user reading another's thread. Skip it and there is nothing to check against — which is the failure mode to think hardest about on this page.

How you prove itInMemoryThreadStore is the reference; test ownership with two distinct users, not one.

Shipped implementationsInMemoryThreadStore (dna.runtime.thread_store)

ThreadPurgePort

dna.runtime.thread_store.ThreadPurgePort · @runtime_checkable · extension point

The two primitives only the party holding the connection can perform, and everything the retention sweep needs from the host.

From the source

As duas primitivas que só quem tem a conexão consegue cumprir, e que são tudo o que :func:sweep_retention precisa do host.

São duas, e não uma varredura inteira, de propósito: o ALGORITMO (qual é o corte, quem venceu, em que ordem apagar, o que reportar) é do SDK, para que dois hosts não interpretem a mesma política de dois jeitos; o que sobra para o host é uma consulta e um delete — o que ele já sabe escrever no seu dialeto. Um host que implementasse a varredura inteira reescreveria a política, e é aí que "30 dias" vira dois números diferentes.

The contract

Member Signature What it must do
expired_threads async def expired_threads(self, *, cutoff: datetime, copilot: str | None=None, limit: int=100) -> Sequence[ThreadRef] As conversas com updated_at anterior a cutoff, mais antigas primeiro, no máximo limit.
delete_thread async def delete_thread(self, thread_id: str) -> bool Apaga a linha de ÍNDICE da conversa. True = havia algo e sumiu.

Swap it when — You have a retention policy — which, for conversation data, is usually a legal position rather than a preference.

The minimum that worksexpired_threads and delete_thread.

What it lights upsweep_retention. Without it the sweep has nothing to call and conversations are kept forever — silently, because nothing errors when deletion simply never happens.

How you prove it — Run sweep_retention against seeded expired threads and assert they are gone.

Shipped implementationsInMemoryThreadStore (dna.runtime.thread_store)

ThreadStorePort

dna.runtime.thread_store.ThreadStorePort · @runtime_checkable · extension point

Composes ThreadTranscriptPort, ThreadIndexPort.

Index plus transcript plus the portability write. Implement this when one component can honestly answer all three; implement the halves separately when it cannot — which is the usual case, and the reason the halves exist.

From the source

O contrato INTEIRO: índice + transcript + a escrita de portabilidade.

Uma implementação disto é o que um host injeta quando quer que conversa seja dado do DNA de ponta a ponta. Um adapter de framework normalmente cumpre só :class:ThreadTranscriptPort; o host compõe as duas metades.

The contract

Member Signature What it must do
import_transcript async def import_transcript(self, export: TranscriptExport, *, owner: str, thread_id: str | None=None, workspace: str | None=None, tenant: str | None=None, copilot: str | None=None, surface: str | None=None) -> ThreadRef Importa um envelope como conversa de owner.

Swap it when — You are backing conversations with a store that owns both the index and the messages.

The minimum that works — Both parent Protocols, plus import_transcript.

What it lights up — Conversation history, ownership checks, and thread export/import end to end.

How you prove itInMemoryThreadStore (dna.runtime.thread_store) implements the whole port and is the readable reference.

Shipped implementationsInMemoryThreadStore (dna.runtime.thread_store) — the only complete implementation; satisfies the port structurally

ThreadTranscriptPort

dna.runtime.thread_store.ThreadTranscriptPort · @runtime_checkable · extension point

The half the framework can answer. The transcript lives inside the framework's own mechanism — LangGraph checkpoints, say — so this port is a projection of it, not a second copy.

From the source

LER a conversa. É o que um adapter de framework consegue prometer: o transcript vive no mecanismo do framework, e esta metade da porta é uma PROJEÇÃO DE LEITURA sobre ele — nenhuma escrita, nenhum turno mais caro.

The contract

Member Signature What it must do
fetch_transcript async def fetch_transcript(self, thread_id: str, *, state_keys: Sequence[str] | None=None) -> Transcript O transcript de thread_id em mensagens AG-UI. state_keys é a allowlist do state que a UI reidrata (None = o default declarado pela implementação, normalmente nenhum; vazia = só as mensagens). Um thread inexistente devolve um :class:Transcript VAZIO, nunca um erro — é o que uma conversa recém-criada legitimamente parece.
export_transcript async def export_transcript(self, thread_id: str, *, state_keys: Sequence[str] | None=None) -> TranscriptExport O envelope portátil do thread. Carrega o histórico visível e SINALIZA (pending_state_dropped) quando deixa um run pendente para trás.

Swap it when — You are adapting a new agent framework and want its conversation history readable through DNA.

The minimum that worksfetch_transcript; export_transcript for portability.

What it lights up — History in the console, and thread export. Without it a conversation runs fine and is simply unreadable afterwards.

How you prove itLangGraphTranscriptStore (dna.runtime.adapters.langgraph_threads) is the worked example of projecting a framework's own store.

Shipped implementationsLangGraphTranscriptStore (dna.runtime.adapters.langgraph_threads); InMemoryThreadStore (dna.runtime.thread_store)

TranscriptPurgePort

dna.runtime.thread_store.TranscriptPurgePort · @runtime_checkable · extension point

Deleting the thread row is not deleting the conversation. The messages live in the framework's store, and only the framework adapter can make them go.

From the source

A metade do apagamento que só o FRAMEWORK consegue: sumir com o transcript de fato (no LangGraph, as linhas de checkpoint do thread).

Protocol separado pela mesma razão que separa leitura de índice: um adapter de framework não sabe de quem é a conversa nem quando ela venceu, e um índice não alcança o checkpoint. Juntar os dois num tipo só obrigaria alguém a stubar metade.

The contract

Member Signature What it must do
delete_transcript async def delete_transcript(self, thread_id: str) -> None Apaga o que o framework guarda do thread. Idempotente: um thread sem checkpoint não é erro.

Swap it when — You implement ThreadPurgePort. These two are a pair: index-side deletion without transcript-side deletion leaves the content on disk while the system reports it deleted, and that gap is the one worth being paranoid about.

The minimum that worksdelete_transcript — really deleting, not tombstoning.

What it lights up — Deletion that is true when someone asks whether the data is gone.

How you prove itLangGraphTranscriptPurge (dna.runtime.adapters.langgraph_threads) removes the thread's checkpoint rows; assert at the store, not through the API that just told you it worked.

Shipped implementationsLangGraphTranscriptPurge (dna.runtime.adapters.langgraph_threads)