Wire parts
Typed, versioned output parts — so clients render declarations instead of regex-parsing model prose.
Here is an incident shape that grows in any app with structured output. The model returns citations as JSON buried in prose, and each client ships a regex to dig it out. It works for months. Then one reply carries a payload the regex half-matches — and the user sees raw JSON rendered as the answer, or worse, sees nothing where the citations should be. Nothing failed; untyped data just flowed through. The conformance suite's label for closing this class: untyped data parts, closed.
Keel's answer is the wire: the sequence of typed parts a turn emits for a client to render. The contract:
- four kinds, closed —
text,progress,custom, and the framework-reservedoutcome, - custom parts are registered — one schema, one version, one merge policy per name; every part a client renders is declared: marker promotion accepts registered names only, and a deliverable contract's part must be registered or the turn refuses to open,
- the model only writes text — structure is spelled as a marker, and a gate promotes it to a validated part before any client sees it,
- rejection is loud — a payload that fails its schema becomes a
marker-rejecteddefect, never a silently rendered blob, - the outcome frame is unforgeable — only the runtime can emit it.
| Kind | Purpose | Counts toward completion? |
|---|---|---|
text | answer prose | non-empty text does |
progress | activity updates | never — narration is not substance |
custom | registered structured output | when declared persist: "content" |
outcome | how the turn ended | emitted by the runtime |
Register a part
Create one registry in src/shared/wire/registry.ts:
import { PartRegistry } from "@keel-dev/core";
export const registry = new PartRegistry();Then define each part in its own file. This is the reference app's sources part:
// src/shared/wire/sources.part.ts
import { z } from "zod";
import { definePart } from "@keel-dev/core";
import { registry } from "./registry.ts";
export const sourcesPart = definePart(registry, {
name: "sources",
version: 1,
schema: z.object({
query: z.string(),
items: z.array(z.object({ title: z.string(), ref: z.string() })),
}),
merge: "replace-by-id",
persist: "content",
fixture: { query: "fixture", items: [{ title: "Result A", ref: "ws://doc-1" }] },
});Import part modules when assembling the app, and list the parts an agent
may say in its emits declaration. Registration is guarded at the door: a
duplicate name, a reserved name (text, progress, outcome), or a name
that fails /^[a-z][a-z0-9-]*$/ all throw — the name becomes the part's
<name> marker tag, so it is constrained before it can break a regex
downstream.
From marker to part
The model can only produce text, so it spells a part as a registered marker:
<sources>{"query":"report","items":[{"title":"Result A","ref":"ws://doc-1"}]}</sources>The say gate extracts complete registered markers from the prose, validates
each payload against its schema, and emits the survivors as typed parts —
the remaining prose flows on as ordinary text. The failure modes each
have a distinct fate:
| The model wrote… | What happens |
|---|---|
| a valid registered marker | promoted to a typed, versioned part |
| a registered marker with invalid JSON or schema | removed, reported as a marker-rejected defect |
| an unknown marker name | left in the prose as ordinary text |
| an opening tag with no close | blocks normal completion — the cut is evidence of truncation |
That last row is the close gate's truncation detector: a partial-tolerant parser would render half a document; the gate refuses it instead, and a length-limited result follows the truncation policy.
With a streaming tier, none of this waits for the end of the reply: text and promoted parts land on the wire as the model streams. The drain holds back an open marker — or a tag that might still be forming — so a marker split across chunks always promotes whole; the client sees the prose flow and then the finished part, never a half-written payload rendered as text.
Nested tags cannot lie
The promoter runs one left-to-right pass over all registered names, and the open-marker check strips complete spans first — so a tag that appears inside another marker's JSON payload is consumed with that payload, never extracted out of it or miscounted as an open marker of its own.
Merge and persistence: declared once, obeyed everywhere
Every client used to hand-guess how streaming updates combine. The part
declares it instead, and reconcile applies it:
| Merge policy | Client reconciliation |
|---|---|
append | keep every part |
replace-by-id | replace the earlier part with the same name and id |
replace-by-name | keep only the latest part of that name |
Marker-promoted parts receive generated ids (sources-0, sources-1, …)
that stay unique across the whole turn — the counter never resets
between drains of a streamed reply — so two markers of the same name never
collapse under replace-by-id. Parts your code emits with an explicit id
(progress updates, forwarded parts) use a stable id to update the same
item in place across a stream. persist
declares the part's weight: "content" for output meant to survive — and
required for any deliverable —
"ephemeral" for temporary UI.
Keep the contract honest over time
Schemas change; saved payloads and client renderers do not change
themselves. Every part carries a fixture, and CI asserts it forever:
// in a test
const results = registry.checkFixtures();
for (const r of results) expect(r.ok).toBe(true);A missing fixture, or one that no longer parses, returns ok: false —
definePart does not run this check for you.
The registry shares definitions; it does not render
Clients get one source of truth for names, versions, schemas, and merge policy — but the registry does not generate your rendering switch. When a schema changes, test saved examples and update the renderers.
Next: Studio — watch parts, gates, and defects on live turns.
