Letting a language model write to your terminal usually means one of two things: a wall of plain text, or a tangle of raw ANSI escape codes the model half-remembered. Neither feels like a real interface, and neither is something you'd trust to drive an interactive CLI. json-render Ink takes a different route. Instead of asking the model to produce output, you ask it to produce a spec — a small, validated JSON document that can only reference components you've pre-approved. That spec then renders into a genuine terminal UI built on Ink, the React renderer for command-line apps.
The package, @json-render/ink, is the terminal target of the broader json-render generative-UI framework from Vercel Labs. The idea is simple but powerful: you define a catalog of allowed components and actions, the AI emits JSON constrained to that catalog, and a renderer turns it into a real interface. Because the same catalog concept powers around sixteen different renderers — React, Vue, Svelte, Solid, React Native, react-pdf, react-email, and more — Ink is just the renderer that happens to draw to your console. Write your component vocabulary once, and the AI can target a website, a mobile screen, a PDF, or a terminal dashboard from the same building blocks.
Why a Spec Beats Free-Form Output
The reason this matters comes down to trust. When you let an LLM "just print something," you have no guarantees: it might invent a component, format a table wrong, or smear control characters across the screen. json-render flips the contract. The model never writes code and never picks from an open-ended vocabulary. It fills in a structured tree where every type must be a component you registered, and every prop is validated against a Zod schema before anything renders.
The flow has four stages. First, you define guardrails — the catalog of components, their prop schemas, and the actions they can trigger. Second, you prompt the model in natural language. Third, the model emits a JSON spec: a flat tree that can only reference your catalog. Fourth, the renderer turns that spec into Ink components and streams it to the terminal. The result is predictable, schema-validated, interactive UI instead of free-form text you have to hope the model got right.
That makes @json-render/ink a natural fit for AI CLI agents, coding assistants, chat-in-the-terminal tools, and any interactive prompt an LLM composes on the fly.
Getting It Onto Your Machine
The package leans on a modern stack — it needs React 19 and Ink 6 as peer dependencies, plus the shared core package. Install everything together:
npm install @json-render/ink @json-render/core ink react
Or with yarn:
yarn add @json-render/ink @json-render/core ink react
One thing worth pinning early: json-render ships the whole monorepo under a single synchronized version, and @json-render/ink depends on the exact matching @json-render/core version. Keep them locked to the same number (here, 0.19.0) to avoid mismatches.
Defining What the AI Is Allowed to Draw
The catalog is the heart of the whole system. It's the list of components and actions the model may use, and nothing outside it will ever render. json-render Ink ships a "batteries included" set of roughly twenty-seven standard components, so you can get a useful catalog with almost no setup.
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/ink/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/ink/catalog";
export const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: standardActionDefinitions,
});
That single catalog now carries everything the renderer and the AI need to know. The standard set covers layout primitives like Box (a flexbox container, the terminal equivalent of a <div>), Text, Spacer, and Newline; content components like Heading, Divider, Badge, Card, Table, List, KeyValue, StatusLine, and even a Markdown renderer; visualizations like Spinner, ProgressBar, Sparkline, and BarChart; and interactive widgets like TextInput, Select, MultiSelect, ConfirmInput, and Tabs. If you've used @inkjs/ui, this vocabulary will feel familiar — the difference is that here it comes wired into a generative, schema-validated layer.
From JSON to Pixels in the Console
With a catalog in hand, rendering a spec is a few lines. The createRenderer helper builds an all-in-one Ink component from your catalog and the standard component implementations:
import { render } from "ink";
import { createRenderer, standardComponents } from "@json-render/ink";
import { catalog } from "./catalog";
const InkRenderer = createRenderer(catalog, standardComponents);
const spec = {
root: "heading",
elements: {
heading: {
type: "Heading",
props: { text: "Hello from the terminal!", level: "h1" },
children: [],
},
},
};
render(<InkRenderer spec={spec} state={{}} />);
The spec format is a flat tree: a root key naming the entry element, plus an elements map. Each element has a type (which must be a catalog component), props, and a children array — but instead of nesting objects, children are referenced by their string IDs. That flatness is deliberate. It's easy for a model to emit, easy to validate, and easy to patch incrementally as a stream arrives.
Nesting works by pointing at child IDs:
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Status" },
children: ["status-1"],
},
"status-1": {
type: "StatusLine",
props: { label: "Build", status: "success" },
children: [],
},
},
};
This renders a bordered card containing a status line with a colored success icon — the kind of structured, glanceable output that would be fiddly to hand-roll in raw Ink and risky to ask a model to produce as plain text.
Closing the Loop With an LLM
This is where json-render Ink earns its keep. The catalog doesn't just constrain rendering — it can generate the system prompt for the model, too, complete with component descriptions, prop schemas, and the available actions:
const systemPrompt = catalog.prompt({
system: "You are a terminal assistant.",
});
Feed that prompt to a model through the Vercel AI SDK, and the model already knows exactly which components exist and what shape their props take. It can't drift outside the vocabulary because the prompt itself describes the boundaries, and validation catches anything that slips through anyway.
For the rendering side, the useUIStream hook progressively builds the UI as the model responds. Rather than waiting for a complete response, it consumes a JSONL patch stream from an API endpoint and updates the terminal as each piece arrives:
import { useUIStream } from "@json-render/ink";
function Assistant() {
const { spec, send, isStreaming } = useUIStream({
api: "/api/generate",
});
// `spec` updates live as patches stream in;
// call `send(prompt)` to kick off a generation.
return <InkRenderer spec={spec} state={{}} />;
}
The official examples/ink-chat app in the repo is the canonical demonstration of this loop: it pairs @json-render/core and @json-render/ink with the Vercel AI SDK and an AI gateway, derives its prompt from the catalog, and renders the streamed JSON live in the terminal. That's the whole pitch made concrete — a terminal chat where the assistant's replies are real, interactive UI, not just text.
Making the UI React to State
A static spec is fine for a one-shot render, but real interfaces respond to data and input. json-render's state model, shared with the core package, works the same way under Ink. Any prop can be an expression instead of a literal value, resolved against a state object at render time:
const spec = {
root: "greeting",
elements: {
greeting: {
type: "Text",
props: {
text: { $template: "Hello, ${/user/name}!" },
},
children: [],
visible: [{ $state: "/user/loggedIn" }],
},
},
};
Here the $template directive interpolates a value from the state path /user/name, and the visible field hides the element entirely unless /user/loggedIn is truthy. Other expression forms include { $state: "/path" } to read a value directly, { $cond, $then, $else } for branching, and { $computed } for derived values.
For interactive components, two-way binding closes the circle. A TextInput or Select can bind to a state path with $bindState, and the useBoundProp hook flows user keystrokes back into the model. Components can also emit actions — the built-in setState action updates the state model, which re-runs visibility checks and re-evaluates every dynamic prop:
const tab = {
type: "Tabs",
props: {
action: "setState",
actionParams: { statePath: "/activeTab", value: "home" },
},
children: [],
};
State watchers go one step further: a watch field fires an action whenever a watched path changes (but not on the initial render), which is exactly what you want for cascading inputs — pick a country, and a dependent city list refreshes itself.
The Directives That Landed in 0.19
The 0.19.0 release added a custom directives API via defineDirective in core, along with a dedicated @json-render/directives package. These give specs a vocabulary for formatting and transforming values without writing component code: $format for Intl-aware dates, currencies, numbers, and percentages; $math for arithmetic; $concat, $join, $count, $truncate, and $pluralize for text wrangling; and $t for i18n. They compose with each other and resolve at render time, so a model can emit a spec that formats a timestamp or pluralizes a label correctly without you hand-coding that logic into a component. Because Ink shares the same core resolution layer, these directives work in the terminal exactly as they do in the web renderers.
A Few Honest Caveats
json-render Ink is a young project, and it's worth going in clear-eyed. It sits at version 0.x — still pre-1.0 — which means frequent releases and the real possibility of breaking changes between minor versions. Pin your versions, and keep @json-render/ink and @json-render/core in lockstep. It's also a Vercel Labs project, which is to say experimental by nature, and it firmly requires the modern stack: React 19 and Ink 6, no older. None of that should scare you off for a side project, an internal tool, or an AI agent experiment — but it's the kind of dependency you'd want to track closely before betting a production CLI on it.
It's also worth being clear about what json-render Ink is not. It doesn't replace Ink; it's built on top of it and inherits the entire Ink ecosystem. If a human is hand-authoring a fixed CLI, plain Ink or @inkjs/ui is the simpler choice. json-render Ink earns its place when the UI is generated — by an LLM or driven by JSON config — and you want schema-validated, guardrailed output that the same catalog can also render to the web, native, or a PDF.
The Takeaway
The clever thing about json-render Ink isn't that it draws nice boxes in your terminal — Ink already does that. It's that it gives an AI a safe, structured way to compose those boxes. By turning "generate a UI" into "fill in this constrained, validated JSON tree," it converts a model's creativity into something predictable enough to ship. You get the expressiveness of generative UI without surrendering control over what actually renders, and you get it in the one place developers spend most of their day: the terminal. For anyone building AI agents, coding assistants, or interactive CLIs where the interface itself is dynamic, that's a genuinely useful trade — and a surprisingly elegant one.