Skip to content

[Proposal] Reusable spec fragments (templates) as a first-class primitive #308

Description

@andreykrupskii

Thanks for building json-render. The catalog-as-guardrail model, SpecStream over JSON Patch, and the multi-renderer story (React, PDF, Ink, image, and so on) are all exactly the right shapes for LLM-driven UI, and they hold up under production stress. Nothing in this proposal changes any of that. It adds one small primitive on top, opt-in, that closes a specific gap we hit at scale.

What we want

Add two new element types to json-render as first-class, opt-in primitives:

  1. Template declares a reusable piece of UI once, built from existing catalog components, with an id and a schema for its props. Declared by the LLM mid-stream, or by the catalog author at build time.
  2. TemplateCall instantiates it anywhere in the tree with concrete prop values.

Specs that don't use them behave exactly like today.

We ship this at Bark AI and want to contribute it back. Checking shape with you before we open a PR.

Why

Our LLM writes analytical answers (P&L breakdowns, per-segment rollups, comparison views) that reuse a small vocabulary of custom composite shapes. Not leaves (a catalog component covers those), but compositions of several catalog components with specific props and structure. A few cases where a catalog component isn't the right home:

  • Composite shapes the LLM invents per answer — the model composes a "product performance card" (image + name + inline sparkline + big metric + chart) for this specific response and uses it in three roles: as the featured entity in the hero at the top, as a supporting visual right below the narrative paragraph that mentions that product, and as both cards in a side-by-side winner-vs-loser comparison further down. Three unrelated tree positions with different parents, no state array to iterate over. The next answer never uses that exact shape. A catalog component would need to be predicted and shipped by the app author; templates let the LLM declare the shape when it needs one and forget it after.
  • Structural variants of a shared shape — tables in every answer want slightly different row layouts (one answer's rows are label + currency + delta; the next wants label + inline bar chart + tags; another wants title + big number). A single catalog TableRow can't cover them all without becoming an omni-prop component that leaks every variant's props into every call. Pushing composition down to individual TableCell components doesn't escape it either — every cell type forks into its own variants (currency locale, chart shape, tag styling), and the omni-prop problem just moves one level deeper. Templates let the LLM define this answer's exact table-row shape once and reuse it across every table in the answer that shares it.
  • Self-similar structures — comment threads, org charts, category hierarchies, nav trees, nested outlines. Same node shape at every depth level. A "comment" template's body renders the comment plus a list of TemplateCalls back to itself for each reply, arbitrary depth, one shape. repeat iterates one level; only recursive templates express the full tree without shipping N distinct components for N depth levels. Legitimate recursion terminates when the data does (empty replies array); a runtime depth cap catches accidental infinite loops.

repeat handles one case: N of the same thing in a row, driven by a state array. None of the cases above fit that shape. Templates address the axes repeat was never meant to cover: shape reuse across unrelated tree positions, per-answer structural variation, and self-similar recursion.

Today the LLM re-emits the whole shape every time. Two costs:

  1. Tokens. Every extra copy is more output tokens, and the total grows linearly with the number of reuses. Templates change the curve: the LLM writes the shape once and refers to it by name, so N reuses cost O(N) short references instead of N copies of a full subtree.
  2. Consistency. When the model re-emits the same shape from memory, it drifts. A bold value in one copy, plain in the next. A currency format in one, a raw number in another. This is the bigger cost in practice. Users notice a table where two rows are formatted slightly differently, and that undermines the report.

repeat also doesn't nest cleanly. See #252 and #256, where the inner statePath only accepts a string, which blocks a list of lists. A primitive that binds a shape to concrete props rather than to a state slice handles both problems in the same way.

The proposal

Two element types. Both are opt-in. The catalog author decides whether to toggle them.

Template: the LLM emits it to declare a reusable shape

{
  "op": "add",
  "path": "/elements/tpl_pnl_row",
  "value": {
    "type": "Template",
    "props": {
      "id": "pnl_row",
      "schema": {
        "type": "object",
        "required": ["variant", "label", "now", "delta"],
        "properties": {
          "variant": { "type": "string", "enum": ["major", "sub", "summary"] },
          "label": { "type": "string" },
          "now": { "type": "number" },
          "delta": { "type": "number" }
        }
      },
      "template": {
        "type": "Row",
        "props": { "variant": { "$prop": "/variant" } },
        "children": [
          { "type": "Value", "props": { "text": { "$prop": "/label" } } },
          {
            "type": "Value",
            "props": {
              "text": { "$format": "currency", "value": { "$prop": "/now" }, "currency": "USD" },
              "bold": true
            }
          },
          { "type": "Delta", "props": { "value": { "$prop": "/delta" } } }
        ]
      }
    }
  }
}

Template renders nothing on its own. It just registers a body under id. It arrives in one patch, not streamed piece by piece.

TemplateCall: the LLM emits it to instantiate the shape with real values

{
  "op": "add",
  "path": "/elements/row_gross",
  "value": {
    "type": "TemplateCall",
    "props": {
      "def": "pnl_row",
      "props": { "variant": "major", "label": "Gross Sales", "now": 2384444, "delta": -0.027 }
    }
  }
}

At render time, the body is expanded and every { "$prop": "/field" } reads the matching value from the caller's props bag. The bag is checked against the Template's JSON Schema. If it doesn't match, a small error span renders in place, like today's catalog validation failures.

One new scope binding: $prop

{ "$prop": "/field" } reads a value from the enclosing TemplateCall's props bag using RFC 6901 JSON Pointer syntax. Same category as the existing built-in scope bindings ($state reads the state model, $item reads the current repeat item); $prop reads the enclosing TemplateCall's props bag. Same path grammar, one rule for the LLM to learn. Outside a template body it resolves to undefined, same convention as $item outside a repeat. Composes freely with directives ($format, $math, $concat) as their value input, same as $state and $item do today.

Sealed scope

A template body starts a fresh iteration scope. The parent's $item and $index do not leak in. $repeat and $item still work inside a body. They just don't accidentally couple the body to whatever loop happens to call it.

Recursion and cycles

A TemplateCall inside a Template body is fine, including self-reference. A template that renders itself is exactly how tree-shaped UIs (comment threads, org charts, nav trees) get expressed. Recursion terminates when the data does (empty replies array, exhausted tree). Only accidental infinite loops need guarding, which a small runtime depth cap handles cleanly. No static cycle rejection: it would block the legitimate recursive case, which is the point of the primitive for tree-shaped data.

Why it should be built in, not built in userland

We considered two other paths and both have real problems.

A custom catalog component that expands a body. Works, but the component becomes renderer-specific. Every renderer (React, Vue, PDF, Ink, image, React Native) needs its own copy. Devtools sees an opaque leaf and can't walk into it. catalog.prompt() has no idea it's not a normal component and describes it that way to the LLM. The LLM ends up learning about a magic component whose props include a whole subtree.

A $compose directive. Directives resolve values on props. Templates need to expand into a subtree. Wrong layer.

If the primitive lives at the spec resolution layer, expansion produces a subtree of ordinary catalog components. Every existing renderer works with no changes. Devtools walks the resolved tree as usual. catalog.prompt() gets one small section describing the primitive, not a magic component.

Two ways to own a Template

The declaration can live in two places, and they coexist.

  1. In the spec. The LLM emits a Template element into the stream, then calls it many times with TemplateCall. Same lifetime as the spec.

  2. In the catalog. The app author registers templates next to components:

    defineCatalog(schema, {
      components: { Row: RowEntry, Value: ValueEntry, Delta: DeltaEntry /* ... */ },
      templates: {
        KPICell: {
          schema: KPICellPropsSchema, // a Zod schema, like every other catalog entry
          template: { /* body */ },
        },
      },
    });

    The LLM sees KPICell in the prompt as a shape it can call, and it stays out of the business of designing it. This is close to how shadcn works: the app author curates the pieces, and the LLM calls them.

A catalog can register KPICell and the LLM can still declare its own ad-hoc template for a shape the catalog author didn't anticipate.

How a Template differs from a normal catalog entry

  • A normal catalog entry is a component the app author writes in code. Every renderer needs one.
  • A Template is a spec fragment. It expands to catalog components you already ship. No renderer work.
  • A normal catalog entry's prop contract is a Zod schema authored in code.
  • A Template's prop contract lives at the same layer as the Template itself: Zod when the Template is registered in the catalog (matching every other catalog entry), JSON Schema when the LLM emits the Template in the spec (matching the JSON wire format). Both checked against the caller's props at expansion.
  • A normal catalog entry is opaque to devtools past its own boundary.
  • A Template is fully walkable. Devtools sees the expanded tree.
  • Adding a normal catalog entry means a code release.
  • Adding a Template means editing the catalog or, for spec-level templates, just emitting one.

Templates don't replace catalog entries. They sit on top and give the LLM (or the catalog author) a way to compose them without writing more components.

It's optional

  • No new required fields on any existing element.
  • catalog.prompt() output is unchanged when no Template is declared or registered.
  • No breaking changes to existing specs.
  • A catalog that doesn't toggle templates behaves exactly like today.
  • $prop is only meaningful inside a template body. Elsewhere it resolves to undefined.

Rough token math

Take the example above. One Template declaration is around 120 tokens. One TemplateCall is around 30. Seven calls (a P&L with seven rows) is about 330 tokens total, vs. about 1120 tokens for seven inline copies of the Row tree. Savings scale with how often the shape is used. In our reports the same shapes reappear across sections, and the savings compound.

What we've built

We have a working implementation shipping in production at Bark. Both element types, both ownership flavors, $prop, sealed scope, and recursive TemplateCall. Happy to share code and open a PR once we agree on shape.

Open questions

  1. Fit. Does the direction fit json-render's plans? Any reason not to have templates as first-class primitives?
  2. Naming. Template and TemplateCall, or something else?
  3. Catalog-author ownership. A dedicated templates slot on defineCatalog (as sketched), or a different integration?

Thanks for the library, and for reading this. Happy to iterate on any of the above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions