Is your feature request related to a problem? Please describe.
LLM-authored props are flaky input — the model often emits values that don't
match the declared zod schema (wrong shape, an object where a string is
expected, or partial values mid-stream). Today these flow straight to the leaf
renderers and are blindly stringified, so malformed input becomes a
user-visible [object Object].
Concrete case: the DSL is positional — ListItem(title, subtitle, …), with arg
order derived from the zod object key order. LLMs frequently fall back to a
React/JSON habit and emit an object as the first arg:
ListItem({ title: "Inbox", subtitle: "12 unread" })
The whole object lands in the title slot, and the renderer does:
// packages/react-ui/src/genui-lib/ListBlock/index.tsx:28
const title = String(item?.props?.title ?? ""); // -> "[object Object]"
Root cause: zod schemas exist (ListItem.title: z.string(),
FollowUpItem.text: z.string(), …) but are not enforced as value-level
validators. In packages/lang-core the schema is used only for positional-arg
→ named-prop mapping, prompt generation, and required-field error enrichment —
there is no safeParse/coercion of leaf prop values, so a wrong-typed value
passes through untouched and is String()-ed at the leaf.
It is not a one-off; the same pattern recurs:
genui-lib/ListBlock/index.tsx:28,29,31 (title, subtitle, actionLabel)
genui-lib/FollowUpBlock/index.tsx:22 (text)
genui-lib/SectionBlock/index.tsx:69,80,81 (trigger, value)
genui-lib/TextContent/index.tsx:32 (text)
In my usecase, integrators currently work around this by defensively overriding
ListBlock/FollowUpBlock in their own libraries.
Describe the solution you'd like
A single, named input-guard layer for LLM-authored props, with two
complementary mechanisms (defense-in-depth):
- Validate-time guard — run the existing
zod schema against leaf prop
values and surface mismatches through the existing onError correction loop
(today used for missing-required props / tool-not-found). Can reject and
route to self-correction — but cannot reject mid-stream, where partial props
are normal.
- Render-time guard — a documented coercion contract (not a hidden helper)
that turns an arbitrary leaf value into display text: object →
.title/.text/.label/.value fallback, arrays handled,
null/undefined → "", and never [object Object]. This is the last line
of defense for streaming and for anything that slips past validation, where
salvage is the only option. Replace the raw String(...) calls in the
leaf/block components above with this contract.
Key design point: salvage is never silent — when the render-time guard
salvages (or validation fails), it reports through the same onError channel,
so the mismatch stays visible to telemetry and the LLM-correction loop instead
of being papered over.
Neither layer subsumes the other, so a render-time guard is still needed even
after validation lands.
Describe alternatives you've considered
- Just fix the two leaf components with a
String() replacement. This is the
current integrator workaround and stays ad-hoc — it relabels the patch without
defining a contract, doesn't report mismatches, and every integrator keeps
re-applying it. Rejected in favor of a first-class, overridable guard.
- Validate-time only (hard parse error). Cannot handle streaming, where
partial/incomplete props are expected — would reject valid in-progress UIs.
- Render-time only (silent salvage). Hides malformed model output and removes
the chance for the self-correction loop to fix it. Hence "salvage + report".
Additional context
Open questions for the design discussion:
- Where should the coercion contract live —
genui-lib/helpers.ts, or a
first-class export so integrators can import/override it (the structural fix
for "every integrator re-patches")?
- Should validate-time mismatches be a hard parse error or a soft error
(render salvaged value + report)? Streaming probably forces soft.
- What is the canonical object→text fallback order
(.title → .text → .label → .value?), and should it be configurable?
I think these point should be considered in tackling this issue concretly
Non-goals: changing the positional-arg DSL itself; schema changes beyond
enforcing existing zod types.
Is your feature request related to a problem? Please describe.
LLM-authored props are flaky input — the model often emits values that don't
match the declared
zodschema (wrong shape, an object where a string isexpected, or partial values mid-stream). Today these flow straight to the leaf
renderers and are blindly stringified, so malformed input becomes a
user-visible
[object Object].Concrete case: the DSL is positional —
ListItem(title, subtitle, …), with argorder derived from the
zodobject key order. LLMs frequently fall back to aReact/JSON habit and emit an object as the first arg:
The whole object lands in the
titleslot, and the renderer does:Root cause:
zodschemas exist (ListItem.title: z.string(),FollowUpItem.text: z.string(), …) but are not enforced as value-levelvalidators. In
packages/lang-corethe schema is used only for positional-arg→ named-prop mapping, prompt generation, and
required-field error enrichment —there is no
safeParse/coercion of leaf prop values, so a wrong-typed valuepasses through untouched and is
String()-ed at the leaf.It is not a one-off; the same pattern recurs:
genui-lib/ListBlock/index.tsx:28,29,31(title,subtitle,actionLabel)genui-lib/FollowUpBlock/index.tsx:22(text)genui-lib/SectionBlock/index.tsx:69,80,81(trigger,value)genui-lib/TextContent/index.tsx:32(text)In my usecase, integrators currently work around this by defensively overriding
ListBlock/FollowUpBlockin their own libraries.Describe the solution you'd like
A single, named input-guard layer for LLM-authored props, with two
complementary mechanisms (defense-in-depth):
zodschema against leaf propvalues and surface mismatches through the existing
onErrorcorrection loop(today used for missing-required props / tool-not-found). Can reject and
route to self-correction — but cannot reject mid-stream, where partial props
are normal.
that turns an arbitrary leaf value into display text: object →
.title/.text/.label/.valuefallback, arrays handled,null/undefined→"", and never[object Object]. This is the last lineof defense for streaming and for anything that slips past validation, where
salvage is the only option. Replace the raw
String(...)calls in theleaf/block components above with this contract.
Key design point: salvage is never silent — when the render-time guard
salvages (or validation fails), it reports through the same
onErrorchannel,so the mismatch stays visible to telemetry and the LLM-correction loop instead
of being papered over.
Neither layer subsumes the other, so a render-time guard is still needed even
after validation lands.
Describe alternatives you've considered
String()replacement. This is thecurrent integrator workaround and stays ad-hoc — it relabels the patch without
defining a contract, doesn't report mismatches, and every integrator keeps
re-applying it. Rejected in favor of a first-class, overridable guard.
partial/incomplete props are expected — would reject valid in-progress UIs.
the chance for the self-correction loop to fix it. Hence "salvage + report".
Additional context
Open questions for the design discussion:
genui-lib/helpers.ts, or afirst-class export so integrators can import/override it (the structural fix
for "every integrator re-patches")?
(render salvaged value + report)? Streaming probably forces soft.
(
.title→.text→.label→.value?), and should it be configurable?I think these point should be considered in tackling this issue concretly
Non-goals: changing the positional-arg DSL itself; schema changes beyond
enforcing existing
zodtypes.