Tutorial: Build a Chat App
Learn Hypen by building a real-time AI chat application with the OpenAI API, step by step
Tutorial: Build a Chat App
In this tutorial, you'll build a fully functional AI chat application powered by the OpenAI API. Along the way, you'll learn every core concept in Hypen — components, layout, styling, state, actions, control flow, and async operations.
Here's what we'll build:
┌──────────────────────────────────────┐
│ AI Chat │
├──────────────────────────────────────┤
│ │
│ ┌──────────────────────────┐ │
│ │ Hello! How can I help? │ │
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ What is Hypen? │ │
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ Hypen is a declarative │ │
│ │ UI language for building │ │
│ │ cross-platform apps... │ │
│ └──────────────────────────┘ │
│ │
│ ● ● ● (typing...) │
│ │
├──────────────────────────────────────┤
│ ┌─────────────────────────┐ ┌────┐ │
│ │ Type a message... │ │Send│ │
│ └─────────────────────────┘ └────┘ │
└──────────────────────────────────────┘Prerequisites
- Bun installed
- The Hypen CLI installed (
bun install -g @hypen-space/cli) - An OpenAI API key (get one at platform.openai.com)
Step 1: Create the project
Scaffold a new Hypen app:
hypen init chat-app
cd chat-appThis creates a project with a starter App component. The structure looks like:
chat-app/
├── hypen.config.ts
├── package.json
├── src/
│ └── components/
│ └── App/
│ ├── component.hypen ← UI template
│ └── component.ts ← State & logic
└── tsconfig.jsonThere are three ways to write a Hypen component:
.hypenonly — a purely declarative component with no state or logic.hypen+.ts— a stateful component: the.hypenfile defines the UI, the.tsfile defines state, actions, and logic.tsonly — define everything in TypeScript, including the UI via the.ui()method
In this tutorial we'll use the .hypen + .ts approach since it cleanly separates UI from logic.
Start the dev server to see changes live:
hypen devOpen http://localhost:3000 — you should see the starter app. Leave this running; it hot-reloads as you edit.
Tip: You can also run
hypen studioto open Hypen Studio, a full in-browser IDE with live preview, state inspector, time-travel debugging, and an integrated terminal.
Step 2: Build the layout
Let's start by building the chat UI structure with static content. Replace the contents of src/components/App/component.hypen:
module App {
Column {
// Header
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
// Message area (empty for now)
Column {
Text("Messages will appear here")
.color("#9CA3AF")
}
.padding(16)
.weight(1)
// Input area
Row {
Input(placeholder: "Type a message...")
.padding(12)
.borderWidth(1)
.borderColor("#D1D5DB")
.borderRadius(8)
.fontSize(16)
.weight(1)
Button {
Text("Send")
.color("#FFFFFF")
.fontWeight("bold")
}
.backgroundColor("#3B82F6")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
}
.gap(8)
.padding(16)
.borderTop(1)
.borderColor("#E5E7EB")
}
.height("100vh")
.backgroundColor("#FFFFFF")
}Let's break down what's new here:
Components are the building blocks. Column, Row, Text, Input, and Button are all built-in components. Components can have arguments (placeholder: "Type a message...") and children inside { }.
Applicators are the chained .method() calls. They style and configure components:
.padding(16)adds spacing inside.gap(8)spaces out children.weight(1)makes a component expand to fill available space (likeflex: 1).borderRadius(8)rounds corners
Layout works with Column (vertical) and Row (horizontal). Our layout is a Column with three sections: header, message area, and input — stacked top to bottom.
Check your browser — you should see a header, a placeholder message area, and an input bar at the bottom.
Step 3: Add state
Now let's make it interactive. We need state to track:
- The list of messages
- What the user is currently typing
Open src/components/App/component.ts and update it to:
import { app } from "@hypen-space/core";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatState {
messages: Message[];
input: string;
}
export default app
.defineState<ChatState>({
messages: [],
input: "",
})
.build();defineState sets the initial state and its TypeScript type. State is reactive — when it changes, the UI updates automatically.
Now wire the state into the template. Update component.hypen:
module App {
Column {
// Header
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
// Message area
Column {
Text("No messages yet. Say hello!")
.color("#9CA3AF")
.padding(24)
.textAlign("center")
}
.padding(16)
.weight(1)
.gap(8)
// Input area
Row {
Input(placeholder: "Type a message...")
.bind(@state.input)
.padding(12)
.borderWidth(1)
.borderColor("#D1D5DB")
.borderRadius(8)
.fontSize(16)
.weight(1)
Button {
Text("Send")
.color("#FFFFFF")
.fontWeight("bold")
}
.onClick(@actions.sendMessage)
.backgroundColor("#3B82F6")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
}
.gap(8)
.padding(16)
.borderTop(1)
.borderColor("#E5E7EB")
}
.height("100vh")
.backgroundColor("#FFFFFF")
}Two key additions:
.bind(@state.input) creates a two-way binding. Whatever the user types goes into state.input, and if state.input changes programmatically, the input field updates too. No action handler needed.
.onClick(@actions.sendMessage) triggers the sendMessage action when the button is clicked. We'll define that action next.
Step 4: Handle sending messages
Let's add the sendMessage action so clicking "Send" adds the message to the list. Update component.ts:
import { app } from "@hypen-space/core";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatState {
messages: Message[];
input: string;
}
export default app
.defineState<ChatState>({
messages: [],
input: "",
})
.onAction("sendMessage", async ({ state }) => {
const text = state.input.trim();
if (!text) return;
// Add the user's message
state.messages.push({
id: Date.now().toString(),
role: "user",
content: text,
});
// Clear the input
state.input = "";
})
.build();Actions are functions that respond to user interactions. They receive a context object with state (the current state, which you can mutate directly) and action (event details including any payload).
Actions can be typed with a generic parameter for their payload. Our sendMessage action doesn't need a payload (it reads from state.input), but if it did, it would look like:
.onAction<{ text: string }>("sendStarter", async ({ action, state }) => {
// action.payload is typed as { text: string }
const text = action.payload!.text;
})State mutations are tracked automatically via a Proxy — just assign values and the UI updates. No setState() or manual sync needed.
Step 5: Render the message list
Now let's display the messages. Replace the message area in component.hypen with a ForEach loop:
module App {
Column {
// Header
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
// Message area
List {
ForEach(items: @state.messages, key: "id") {
Row {
Column {
Text("${item.content}")
.color("${item.role == 'user' ? '#FFFFFF' : '#111827'}")
.fontSize(15)
}
.backgroundColor("${item.role == 'user' ? '#3B82F6' : '#F3F4F6'}")
.padding(12)
.borderRadius(12)
.maxWidth("75%")
}
.horizontalAlignment("${item.role == 'user' ? 'end' : 'start'}")
.fillMaxWidth(true)
}
}
.padding(16)
.weight(1)
.gap(8)
// Input area
Row {
Input(placeholder: "Type a message...")
.bind(@state.input)
.onKey(@actions.sendMessage)
.padding(12)
.borderWidth(1)
.borderColor("#D1D5DB")
.borderRadius(8)
.fontSize(16)
.weight(1)
Button {
Text("Send")
.color("#FFFFFF")
.fontWeight("bold")
}
.onClick(@actions.sendMessage)
.backgroundColor("#3B82F6")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
}
.gap(8)
.padding(16)
.borderTop(1)
.borderColor("#E5E7EB")
}
.height("100vh")
.backgroundColor("#FFFFFF")
}Several new concepts here:
ForEach iterates over an array from state. items: @state.messages is the data source, and key: "id" tells Hypen how to track each item for efficient updates (always use a unique key for dynamic lists).
@item references the current element inside a ForEach. ${item.content} accesses the message text, ${item.role} its role.
Expression bindings like "${item.role == 'user' ? '#3B82F6' : '#F3F4F6'}" let you compute values inline. User messages get a blue background; assistant messages get gray.
List wraps the ForEach to make the message area scrollable.
.onKey(@actions.sendMessage) fires the action when Enter is pressed, so users can send messages with the keyboard too.
Try it — type a message and press Enter or click Send. You should see your message appear as a blue bubble on the right side.
Step 6: Connect the OpenAI API
Now for the exciting part — making the AI respond. First, install the OpenAI package:
bun add openaiCreate a helper file at src/ai.ts:
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export async function chat(messages: ChatMessage[]): Promise<string> {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages,
});
return response.choices[0]?.message?.content ?? "";
}Now update component.ts to call the API after sending a message:
import { app } from "@hypen-space/core";
import { chat, type ChatMessage } from "../../ai";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatState {
messages: Message[];
input: string;
isLoading: boolean;
}
export default app
.defineState<ChatState>({
messages: [],
input: "",
isLoading: false,
})
.onAction("sendMessage", async ({ state }) => {
const text = state.input.trim();
if (!text || state.isLoading) return;
// Add the user's message
state.messages.push({
id: Date.now().toString(),
role: "user",
content: text,
});
state.input = "";
// Call the AI
state.isLoading = true;
const history: ChatMessage[] = state.messages.map((m) => ({
role: m.role,
content: m.content,
}));
try {
const reply = await chat(history);
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: reply,
});
} catch {
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
});
} finally {
state.isLoading = false;
}
})
.build();Notice we added isLoading to state. Let's show a typing indicator while we wait for the AI. We'll also disable the send button.
Before running, set your API key:
export OPENAI_API_KEY="sk-..."
hypen devStep 7: Add a loading indicator
Update the template to show a typing indicator and disable input while loading:
module App {
Column {
// Header
Row {
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
Spacer()
Text("${state.messages.length} messages")
.fontSize(13)
.color("#9CA3AF")
}
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
.verticalAlignment("center")
// Message area
List {
ForEach(items: @state.messages, key: "id") {
Row {
Column {
Text("${item.content}")
.color("${item.role == 'user' ? '#FFFFFF' : '#111827'}")
.fontSize(15)
}
.backgroundColor("${item.role == 'user' ? '#3B82F6' : '#F3F4F6'}")
.padding(12)
.borderRadius(12)
.maxWidth("75%")
}
.horizontalAlignment("${item.role == 'user' ? 'end' : 'start'}")
.fillMaxWidth(true)
}
// Typing indicator
If(condition: @state.isLoading) {
Row {
Column {
Text("Thinking...")
.color("#6B7280")
.fontSize(14)
}
.backgroundColor("#F3F4F6")
.padding(12)
.borderRadius(12)
}
}
}
.padding(16)
.weight(1)
.gap(8)
// Input area
Row {
Input(placeholder: "Type a message...")
.bind(@state.input)
.onKey(@actions.sendMessage)
.padding(12)
.borderWidth(1)
.borderColor("#D1D5DB")
.borderRadius(8)
.fontSize(16)
.weight(1)
Button {
Text("${state.isLoading ? '...' : 'Send'}")
.color("#FFFFFF")
.fontWeight("bold")
}
.onClick(@actions.sendMessage)
.backgroundColor("${state.isLoading ? '#93C5FD' : '#3B82F6'}")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
.disabled(${state.isLoading})
}
.gap(8)
.padding(16)
.borderTop(1)
.borderColor("#E5E7EB")
}
.height("100vh")
.backgroundColor("#FFFFFF")
}New concepts:
If conditionally renders UI based on a boolean. If(condition: @state.isLoading) shows the "Thinking..." indicator only while waiting for the API response.
Dynamic applicators like .backgroundColor("${state.isLoading ? '#93C5FD' : '#3B82F6'}") change styles reactively. The button fades when loading.
.disabled() prevents interaction while loading, so users can't spam the API.
Step 8: Add a system prompt
Let's give the AI some personality. Update src/ai.ts:
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const SYSTEM_PROMPT = `You are a friendly, concise assistant.
Keep responses brief — a few sentences at most.
Use a casual, warm tone.`;
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export async function chat(messages: ChatMessage[]): Promise<string> {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
],
});
return response.choices[0]?.message?.content ?? "";
}Step 9: Add a welcome message
Let's show a welcome message when the chat is empty. Use the onCreated lifecycle hook to set it up in component.ts:
import { app } from "@hypen-space/core";
import { chat, type ChatMessage } from "../../ai";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatState {
messages: Message[];
input: string;
isLoading: boolean;
}
export default app
.defineState<ChatState>({
messages: [],
input: "",
isLoading: false,
})
.onCreated(async (state) => {
// Add a welcome message on load
state.messages.push({
id: "welcome",
role: "assistant",
content: "Hey! I'm your AI assistant. Ask me anything.",
});
})
.onAction("sendMessage", async ({ state }) => {
const text = state.input.trim();
if (!text || state.isLoading) return;
state.messages.push({
id: Date.now().toString(),
role: "user",
content: text,
});
state.input = "";
state.isLoading = true;
const history: ChatMessage[] = state.messages.map((m) => ({
role: m.role,
content: m.content,
}));
try {
const reply = await chat(history);
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: reply,
});
} catch {
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
});
} finally {
state.isLoading = false;
}
})
.build();onCreated is a lifecycle hook that runs once when the module first mounts. It receives state and an optional context. Great for fetching initial data or setting up defaults.
There's also onDestroyed for cleanup (clearing timers, closing connections, etc.) when a module is unmounted.
Step 10: Add a clear button
Let's add a button to clear the conversation. Add a clearChat action in component.ts:
.onAction("clearChat", async ({ state }) => {
state.messages = [{
id: "welcome",
role: "assistant",
content: "Hey! I'm your AI assistant. Ask me anything.",
}];
})Add it to the header in component.hypen, replacing the existing header Row:
// Header
Row {
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
Spacer()
Button {
Text("Clear")
.color("#6B7280")
.fontSize(14)
}
.onClick(@actions.clearChat)
.backgroundColor("transparent")
.padding(horizontal: 12, vertical: 6)
.borderRadius(6)
.borderWidth(1)
.borderColor("#E5E7EB")
}
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
.verticalAlignment("center")The final code
Here's everything together.
src/ai.ts
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const SYSTEM_PROMPT = `You are a friendly, concise assistant.
Keep responses brief — a few sentences at most.
Use a casual, warm tone.`;
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export async function chat(messages: ChatMessage[]): Promise<string> {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
],
});
return response.choices[0]?.message?.content ?? "";
}src/components/App/component.ts
import { app } from "@hypen-space/core";
import { chat, type ChatMessage } from "../../ai";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatState {
messages: Message[];
input: string;
isLoading: boolean;
}
export default app
.defineState<ChatState>({
messages: [],
input: "",
isLoading: false,
})
.onCreated(async (state) => {
state.messages.push({
id: "welcome",
role: "assistant",
content: "Hey! I'm your AI assistant. Ask me anything.",
});
})
.onAction("sendMessage", async ({ state }) => {
const text = state.input.trim();
if (!text || state.isLoading) return;
state.messages.push({
id: Date.now().toString(),
role: "user",
content: text,
});
state.input = "";
state.isLoading = true;
const history: ChatMessage[] = state.messages.map((m) => ({
role: m.role,
content: m.content,
}));
try {
const reply = await chat(history);
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: reply,
});
} catch {
state.messages.push({
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
});
} finally {
state.isLoading = false;
}
})
.onAction("clearChat", async ({ state }) => {
state.messages = [{
id: "welcome",
role: "assistant",
content: "Hey! I'm your AI assistant. Ask me anything.",
}];
})
.build();src/components/App/component.hypen
module App {
Column {
// Header
Row {
Text("AI Chat")
.fontSize(20)
.fontWeight("bold")
.color("#111827")
Spacer()
Button {
Text("Clear")
.color("#6B7280")
.fontSize(14)
}
.onClick(@actions.clearChat)
.backgroundColor("transparent")
.padding(horizontal: 12, vertical: 6)
.borderRadius(6)
.borderWidth(1)
.borderColor("#E5E7EB")
}
.padding(16)
.borderBottom(1)
.borderColor("#E5E7EB")
.verticalAlignment("center")
// Message area
List {
ForEach(items: @state.messages, key: "id") {
Row {
Column {
Text("${item.content}")
.color("${item.role == 'user' ? '#FFFFFF' : '#111827'}")
.fontSize(15)
}
.backgroundColor("${item.role == 'user' ? '#3B82F6' : '#F3F4F6'}")
.padding(12)
.borderRadius(12)
.maxWidth("75%")
}
.horizontalAlignment("${item.role == 'user' ? 'end' : 'start'}")
.fillMaxWidth(true)
}
// Typing indicator
If(condition: @state.isLoading) {
Row {
Column {
Text("Thinking...")
.color("#6B7280")
.fontSize(14)
}
.backgroundColor("#F3F4F6")
.padding(12)
.borderRadius(12)
}
}
}
.padding(16)
.weight(1)
.gap(8)
// Input area
Row {
Input(placeholder: "Type a message...")
.bind(@state.input)
.onKey(@actions.sendMessage)
.padding(12)
.borderWidth(1)
.borderColor("#D1D5DB")
.borderRadius(8)
.fontSize(16)
.weight(1)
Button {
Text("${state.isLoading ? '...' : 'Send'}")
.color("#FFFFFF")
.fontWeight("bold")
}
.onClick(@actions.sendMessage)
.backgroundColor("${state.isLoading ? '#93C5FD' : '#3B82F6'}")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
.disabled(${state.isLoading})
}
.gap(8)
.padding(16)
.borderTop(1)
.borderColor("#E5E7EB")
}
.height("100vh")
.backgroundColor("#FFFFFF")
}What you've learned
Here's a recap of every Hypen concept covered in this tutorial:
| Concept | What it does | Example |
|---|---|---|
| Components | UI building blocks | Text("Hello"), Column { }, Button { } |
| Applicators | Style and configure components | .fontSize(18), .padding(16), .borderRadius(8) |
| Layout | Arrange components | Column (vertical), Row (horizontal), List (scrollable) |
| Modules | Components with state and logic | module App { ... } |
| State | Reactive data | .defineState({ count: 0 }), ${state.count} |
.bind() | Two-way data binding | Input().bind(@state.input) |
| Actions | Handle events | .onClick(@actions.send), .onAction("send", ...) |
| ForEach | Render lists | ForEach(items: @state.messages, key: "id") { ... } |
| If | Conditional rendering | If(condition: @state.isLoading) { ... } |
| Expression bindings | Compute values inline | "${item.role == 'user' ? 'blue' : 'gray'}" |
| Lifecycle hooks | Setup and cleanup | .onCreated(...), .onDestroyed(...) |
| Async actions | API calls, async work | async ({ state }) => { await fetch(...) } |
Challenges
Want to keep going? Try extending the app:
1. Add timestamps
Add a timestamp field to each message and display it:
Text("${item.timestamp}")
.fontSize(11)
.color("#9CA3AF")
.marginTop(4)2. Add conversation starters
Show quick-action buttons when there are no user messages:
If(condition: @state.showStarters) {
Row {
Button { Text("Tell me a joke") }
.onClick(@actions.sendStarter, text: "Tell me a joke")
Button { Text("Write a haiku") }
.onClick(@actions.sendStarter, text: "Write a haiku")
}
.gap(8)
}Use a typed action to handle the payload:
.onAction<{ text: string }>("sendStarter", async ({ action, state }) => {
state.input = action.payload!.text;
// then trigger sendMessage...
})3. Add error handling with retry
Use When to show different states:
When(value: @state.status) {
Case(match: "error") {
Row {
Text("Failed to send")
.color("#EF4444")
Button { Text("Retry") }
.onClick(@actions.retry)
}
}
}4. Multiple conversations
Add a sidebar with conversation history using state for a list of conversations and ForEach to render them.
Next steps
- Components — Full list of built-in components
- Control Flow — Deep dive into ForEach, When, If
- Styling — Complete applicator reference
- State & Modules — Advanced state patterns
- Error Handling — Catching and recovering from errors