HypenHypen
Guide

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-app

This 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.json

There are three ways to write a Hypen component:

  • .hypen only — a purely declarative component with no state or logic
  • .hypen + .ts — a stateful component: the .hypen file defines the UI, the .ts file defines state, actions, and logic
  • .ts only — 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 dev

Open http://localhost:3000 — you should see the starter app. Leave this running; it hot-reloads as you edit.

Tip: You can also run hypen studio to 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 (like flex: 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 openai

Create 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 dev

Step 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:

ConceptWhat it doesExample
ComponentsUI building blocksText("Hello"), Column { }, Button { }
ApplicatorsStyle and configure components.fontSize(18), .padding(16), .borderRadius(8)
LayoutArrange componentsColumn (vertical), Row (horizontal), List (scrollable)
ModulesComponents with state and logicmodule App { ... }
StateReactive data.defineState({ count: 0 }), ${state.count}
.bind()Two-way data bindingInput().bind(@state.input)
ActionsHandle events.onClick(@actions.send), .onAction("send", ...)
ForEachRender listsForEach(items: @state.messages, key: "id") { ... }
IfConditional renderingIf(condition: @state.isLoading) { ... }
Expression bindingsCompute values inline"${item.role == 'user' ? 'blue' : 'gray'}"
Lifecycle hooksSetup and cleanup.onCreated(...), .onDestroyed(...)
Async actionsAPI calls, async workasync ({ 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