keel

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-reserved outcome,
  • 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-rejected defect, never a silently rendered blob,
  • the outcome frame is unforgeable — only the runtime can emit it.
KindPurposeCounts toward completion?
textanswer prosenon-empty text does
progressactivity updatesnever — narration is not substance
customregistered structured outputwhen declared persist: "content"
outcomehow the turn endedemitted 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>
model text…found it <citations>{…}</citations>promotethe say gateschema okrejectedpart: citations · v1typed, versioned, reconcilabledefect: marker-rejectedsurfaced — never silently renderedplain text flows through as a text part;an unclosed marker at settle fails the turn (marker-open)
The model can only write text, so it spells structure as a marker. The say gate validates the payload and promotes it to a typed part — clients never parse model output.

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 markerpromoted to a typed, versioned part
a registered marker with invalid JSON or schemaremoved, reported as a marker-rejected defect
an unknown marker nameleft in the prose as ordinary text
an opening tag with no closeblocks 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 policyClient reconciliation
appendkeep every part
replace-by-idreplace the earlier part with the same name and id
replace-by-namekeep 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.