# Build an AI character chat app with the eroq API

> A complete pattern for a character chat product — persona prompts, history windows, streaming UI, credit budgeting — with working code against the eroq API.

Published 2026-08-22 · eroq.ai — canonical: https://eroq.ai/blog/build-ai-character-chat-app


This is the tutorial we wish had existed when we shipped our first character product. One evening of work, one working pattern: persona in, streamed replies out, costs you can predict.

## The architecture in one paragraph

Your backend owns the API key, the character sheets, and the conversation store. The client talks only to *your* backend. Every turn, you assemble `[system] + [windowed history] + [new user message]`, send it to `/v1/chat/completions`, and stream the reply back down. The API is stateless — which means your product owns memory, and that is a feature.

## 1. The persona prompt

Keep conduct and identity separate. Identity is short; conduct is strict:

```js
function personaPrompt(character) {
  return [
    `You are ${character.name}. ${character.oneLineIdentity}`,
    character.voiceNotes,                    // "dry humor, short sentences, hates small talk"
    'Stay fully in character. Never mention being an AI or add out-of-character notes.',
    'Actions in *asterisks*. Advance the scene, then hand it back.',
  ].join(' ')
}
```

Resist the lore dump. A 2,000-word backstory in the system prompt costs you drift, not depth — inject specific facts only when the scene touches them.

## 2. The turn, streamed

```js
export async function characterReply(character, history, userMessage, onDelta) {
  const res = await fetch('https://eroq.ai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.EROQ_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'eroq-rp-mini',                 // 1 credit; rp-plus where it matters
      stream: true,
      messages: [
        { role: 'system', content: personaPrompt(character) },
        ...history.slice(-30),               // the window — see below
        { role: 'user', content: userMessage },
      ],
    }),
  })

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let full = ''
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const events = buffer.split('\n\n')
    buffer = events.pop() ?? ''
    for (const event of events) {
      const data = event.replace(/^data: /, '')
      if (data === '[DONE]') continue
      const chunk = JSON.parse(data)
      const delta = chunk.choices?.[0]?.delta?.content
      if (delta) { full += delta; onDelta(delta) }
    }
  }
  return full
}
```

Pipe `onDelta` to your UI over your own SSE or WebSocket. First tokens on screen in under a second is what makes a character feel present.

## 3. The history window

Send the last 20–40 turns, never everything. Older context adds drift faster than it adds memory. For long relationships, run a cheap summarization pass every N messages and prepend the summary as a system-adjacent note — one `eroq-rp-mini` call (1 credit) buys you a durable memory line.

Filter the window before sending: any turn where the model broke character should not be in the context you send back, or you will get more of it.

## 4. Costing it

The whole point of flat pricing is that this table exists before you launch:

| User behavior | Calls/day | Model | Credits/day |
| --- | --- | --- | --- |
| Casual (20 msgs) | 20 | rp-mini | 20 |
| Engaged (80 msgs) | 80 | rp-mini | 80 |
| Power + quality scenes | 150 | mix | ~250 |

At pack rates (~$0.01/credit or less), an engaged user costs about $0.80/day of model spend. Price your subscription accordingly, and add [images](/docs/images) (10 credits) and [voice lines](/docs/speech) as premium moments rather than defaults.

## 5. The two errors to handle well

- **`402 insufficient_credits`** — your wallet, not your bug. Alert yourself, top up, and degrade gracefully (queue the message, tell the user the character "stepped away").
- **`502` / stream error events** — refunded automatically. Retry once with backoff; the user sees a typing indicator, not an apology.

Everything else in the [error guide](/docs/errors) is standard.

## Ship it

That is genuinely the whole pattern: a conduct prompt, a window, a stream, a budget. Get a key, paste section 2, and your first character is talking in the [quickstart's](/docs/quickstart) five minutes — the free 50 credits cover the whole evening of testing.
