I am excited to announce the release of Hashbrown v0.3. This release focuses on four areas we’ve been aching to improve: MCP servers, documentation, improved system instructions, and supporting open-weight models such as gpt-oss:120b. Let’s eat!
Open-Weight Models
Hashbrown requires a network request for all its functionality. Need a completion? Well, we’ve got to reach out to a server and hit up your preferred, paid LLM provider. It’s the unfortunate reality of where AI is today, with the best-in-class large language models requiring more compute than any of us carry in our pockets.
Still, my vision of Hashbrown is that one day, maybe in a few years, on-device models will be sufficient for a variety of use cases that we categorize under generative user interfaces. Powerful autocompletes, predictions, and self-organizing views will be possible with models supplied by Chrome or Edge’s window.ai. In fact, this Fall, Apple devices will have quite a capable LLM that’ll run locally on-device. This is where we are headed, and we are optimizing Hashbrown’s design for it.
As a stepping stone, we are excited to announce support for the gpt-oss models (along with many other open-weight models) through our support for Ollama. Some of Hashbrown’s most dazzling showcases work well with gpt-oss:120b, and simpler use cases, such as predictions and suggestions, work with gpt-oss:20b. That means you can get started with Hashbrown using completely open-source software without having to pay an LLM provider.
Let’s talk about the changes required to get there:
Simplifying the UI Schema
Underneath response_format when generating completions with your LLM provider.
The schema we generate must be highly efficient for streaming. LLMs can take a long time to respond. We want generative UIs to feel snappy, so we need to be able to parse the incoming JSON as eagerly as possible to minimize the delay between the LLM thinking of a UI and Hashbrown rendering that UI.
There are many technical challenges to building a streaming JSON parser, but none as challenging as handling the anyOf case.
Let’s model a few UI elements using Skillet, Hashbrown’s schema language, for rendering lights, scenes, and markdown in an app:
const elementSchema = s.anyOf([
s.object('Markdown', {
$tagName: s.literal('app-markdown'),
$props: s.object('the props for the Markdown element', {
data: s.string('the markdown content to display'),
}),
}),
s.object('Light', {
$tagName: s.literal('app-markdown'),
$props: s.object('the props for the Light element', {
id: s.string('the ID of the light to display'),
}),
}),
s.object('Scene', {
$tagName: s.literal('app-scene'),
$props: s.object('the props for the Scene element', {
id: s.string('the id of the scene to display'),
}),
}),
]);
Given a JSON fragment like this:
{
"$props": {
"id": "bab0ef07-f3d4-4c50-8cc3-9fa331e4d26b"
},
"$tagName": "app-
At this point, we don’t know because we are missing information in $tagName, even though we have a complete UUID. It’s unfortunate that we are at this step because if we knew the tag name, we could have rendered the component.
We learned early on that we couldn’t rely on key name ordering being preserved in our schema. OpenAI does a great job of it, but both Gemini and Writer were inconsistent, and we couldn’t find a way to force $tagName to be generated first. This is especially frustrating when handling components with $children, since the LLM can spend a significant amount of time generating child components before giving us enough information to render the parent.
To overcome this, we generate schema that asks the LLM to emit a wrapper object around each element in an anyOf. In the previous version of Hashbrown, the above would actually have been emitted like:
{
"2": {
"$props": {
"id": "bab0ef07-f3d4-4c50-8cc3-9fa331e4d26b"
},
"$tagName": "app-
With zero-based indexing, we can eagerly infer that it has selected the app-scene component and can select that branch in our parser.
The problem is that underpowered models (like gpt-oss:120b) consistently confuse that with the index of the element in the UI, not the index of the choice in the anyOf. In our testing, gpt-oss:120b would often hallucinate anyOf choices, breaking our parser completely.
In the newest version of Hashbrown, we have simplified our approach to enveloping anyOf elements. Now, if each element of your anyOfs contains a single, unique s.literal, we use that as the key in our discriminator envelope and strip it from the containing object schema. That means the above now gets emitted as:
{
"app-scene": {
"$props": {
"id": "bab0ef07-f3d4-4c50-8cc3-9fa331e4d26b"
},
This extra bit of intelligence inside Skillet produces simpler JSON schemas that are easier for models like gpt-oss:120b to generate.
Easier Prompting
To get the most out of these lower-powered models, developers will need to write few-shot prompts, where a few examples in the system instruction will help the LLM understand how to generate valid JSON. We want this to be as easy as possible for developers without having to document exactly how the underlying JSON representation of our UI chat works. It’s not that there’s anything secret about the representation; it’s just cumbersome to write by hand.
With Hashbrown v0.3, we are introducing a new prompt string template literal function. This is an experimental API that lets you write UI examples in your system prompts that are then automatically down-leveled into the JSON representation:
import { prompt } from '@hashbrownai/core';
export function Chat() {
const chat = useUiChat({
system: prompt`
You are a helpful assistant.
### EXAMPLES
<user>What are the lights in the living room?</user>
<assistant>
<tool-call>getLights</tool-call>
</assistant>
<assistant>
<ui>
<Card title="Living Room Lights">
<Light lightId="..." />
<Light lightId="..." />
</Card>
</ui>
</assistant>
`,
components: [...],
});
}
The prompt function will parse anything inside of the brackets and do the following for you:
- Validate that the examples match the list of components provided
- Validate that the props have been set correctly based on their schema definitions
- Convert the examples into the appropriate JSON representation
That means that the above system prompt gets turned into this:
You are a helpful assistant.
### EXAMPLES
<user>What are the lights in the living room?</user>
<assistant>
<tool-call>getLights</tool-call>
</assistant>
<assistant>
{
"ui": [
{
"app-card": {
"$props": {
"title": "Living Room Lights"
},
"$children": [
{
"app-light": {
"$props": {
"lightId": "..."
}
}
},
{
"app-light": {
"$props": {
"lightId": "..."
}
}
}
]
}
}
]
}
</assistant>
That would have been a drag to write out by hand!
I’m personally really excited about this feature. A few examples in a prompt can go a long way to achieving the right behavior from an LLM, especially ones like gpt-oss:120b. The hope is that this makes it easier to build high-quality experiences on top of UI chat. Depending on its reception, we plan to expand the parsing and validation pipeline to support validation of system prompts for structured outputs, tool calls, and assistant turns. I’d also love to ship syntax highlighting for strings tagged with prompt.
Ollama Adapter
The final piece of the puzzle in supporting open-weights models is Ollama. The Ollama client simplifies running open models locally, on a server, or through their Turbo product offering. With Hashbrown v0.3, we are excited to launch support for Ollama with @hashbrownai/ollama.
Using it to set up a backend API route for Hashbrown to consume is as simple as our other adapter packages:
import { HashbrownOllama } from '@hashbrownai/ollama';
app.post('/chat', async (req, res) => {
const stream = HashbrownOllama.stream.text({
// Optional: use Ollama Turbo
// turbo: { apiKey: process.env.OLLAMA_API_KEY! },
request: req.body, // must be Chat.Api.CompletionCreateParams
});
res.header('Content-Type', 'application/octet-stream');
for await (const chunk of stream) {
res.write(chunk); // Pipe each encoded frame as it arrives
}
res.end();
});
From there, you can consume any open model you’ve already configured with Ollama:
const chat = useChat({
model: 'gpt-oss:120b',
system: 'You are a helpful assistant',
});
We’ll post a full writeup on our Ollama support in an upcoming blog post.
MCP Server Support
Hashbrown is now interoperable with MCP servers. Our initial implementation is minimal, and requires developers to use the @modelcontextprotocol/sdk on their own to load and call tools hosted on an MCP server. Don’t worry—it’s not too complicated! Brian Love has written up a full recipe on how to integrate an MCP server with Hashbrown.
We have also added a new sample application to Hashbrown demonstrating consuming MCP. We call it the Spotify Sample, and it demonstrates using generative UI to assemble custom workflows. In the context of the sample app, players of a Spotify game can describe the rules of a queue-management game. For example, a user can say “in this game, each player has to pick a song that starts with next letter of the alphabet.” The app then assembles a user interface that adheres to the rules of the game. It uses a custom Spotify MCP server to queue up songs in your Spotify client, letting you play the game with your friends while collaborating on your Spotify queue.
You can check out the source code for the Spotify Sample in the Hashbrown monorepo, with complete instructions on how to get it running. We currently have an Angular implementation and will be working on a React implementation soon (contributors welcome!). If you’re curious about the architecture and design, stay tuned for our appearance on Web Dev Challenge to see how we made it.
Overall, we are choosing to exercise restraint in our MCP API surface with this limited integration. It’s not clear to us how applicable MCP will be for creating apps with generative UI. If you have use cases for MCP with Hashbrown, we would love to hear from you to help shape future MCP APIs. Drop me a line with feedback via email: mike@liveloveapp.com
New Docs Website
We’ve redesigned Hashbrown’s docs site and written extensive new documentation to go along with it. There’s never been a better time to get started, with a full guide on how Hashbrown works and a new set of recipes for building AI-powered user interfaces. We’ve also cleaned up API reference documentation.
Workshops
Want to learn how to build generative user interfaces? We’ve just launched workshops for React and Angular that offer developers a complete deep-dive on leveraging large-language models to build generative user interfaces. We cover everything from simple completions to tool calling and leveraging Hashbrown’s JavaScript runtime. We'd love to see you there!
Up Next
We are going to keep maturing our approach to generating text, structured data, and user interfaces. Alongside this work, we plan to start integrating the first parts of our support for audio (both text to speech and speech to text) in Hashbrown v0.4. You can check out the v0.4 Milestone on GitHub to see what all we have planned. If you'd like to contribute, send me an email at mike@liveloveapp.com and I'll personally get you onboarded into the project.
Shout out to JID's "God Does Like Ugly" for this release.
