Chapter 11: Practice - Roleplay and Status Panel

In this chapter we build a small roleplay application with a live status panel. It combines timelines, prompt fragments, LLM streaming, output parsing, data rendering, and DOM mounting.

What We Will Build

The app has two areas:

  • a dialogue area showing user and character messages
  • a status panel showing structured character state

The model returns both natural language dialogue and structured status data. ChronoAI stores each part on its own timeline so the UI can update independently.

Timeline Architecture

user-input
  -> messages-ready
  -> LlmCall
  -> ai-response
  -> parsed-output
  -> dialogue-html
  -> status-html

Suggested timelines:

TimelinePurpose
user-inputPlayer input
messages-readyLLM message history
ai-responseRaw streamed model output
parsed-outputParsed structured result
dialogueAssistant dialogue text
statusCharacter state object
dialogue-htmlRendered dialogue
status-htmlRendered status panel

Step 1: Design the AI Output Format

A stable output format makes parsing predictable.

<dialogue>
The character replies here.
</dialogue>

<status>
{
  "mood": "calm",
  "trust": 42,
  "location": "library"
}
</status>

Use prompt fragments to teach the model this format.

---
name: roleplay.output-format
tags: [system, output-format]
priority: 90
---
Always return <dialogue> and <status> blocks. The status block must contain valid JSON.

Step 2: Write RoleplayFeature

export const RoleplayFeature = defineFeature('roleplay', () => {
  const userInput = useTimeline<string>({ name: 'user-input' });
  const aiResponse = useTimeline<string>({ name: 'ai-response', retain: 1 });

  const parsed = useTimeline<RoleplayOutput>({
    name: 'parsed-output',
    fill: reactionFrame({
      triggers: { aiResponse },
      compute({ aiResponse }) {
        return parseRoleplayOutput(aiResponse?.value ?? '');
      },
    }),
  });

  useTimeline<string>({
    name: 'dialogue',
    fill: reactionFrame({
      triggers: { parsed },
      compute({ parsed }) {
        return parsed?.value?.dialogue ?? '';
      },
    }),
  });

  useTimeline<Record<string, unknown>>({
    name: 'status',
    fill: reactionFrame({
      triggers: { parsed },
      compute({ parsed }) {
        return parsed?.value?.status ?? {};
      },
    }),
  });
});

The parser can be simple at first. Later you can replace it with @chronoai/toolkit/output-parser.

Step 3: Connect the Toolkit

agent
  .use(PromptLoaderFeature, { files: promptFiles })
  .use(HistoryFeature, {
    sources: [
      { timeline: 'user-input', role: 'user' },
      { timeline: 'dialogue', role: 'assistant' },
    ],
    target: 'messages-ready',
  })
  .use(LlmFeature, {
    apiKey: process.env.OPENAI_API_KEY!,
    model: 'gpt-4o-mini',
  })
  .use(RoleplayFeature)
  .use(DataRenderFeature, {
    source: 'status',
    target: 'status-html',
  })
  .use(DomMountFeature, {
    mounts: [
      { timeline: 'dialogue', target: '#dialogue', mode: 'text' },
      { timeline: 'status-html', target: '#status', mode: 'html' },
    ],
  });

Each toolkit feature handles one concern. The Agent coordinates the graph.

Step 4: Build the Frontend

A minimal HTML shell is enough:

<div id="dialogue"></div>
<div id="status"></div>
<form id="form">
  <input id="input" />
  <button>Send</button>
</form>

Wire the form to the Agent:

form.addEventListener('submit', async event => {
  event.preventDefault();
  const text = input.value.trim();
  if (!text) return;

  await agent.advance();
  await agent.write('user-input', text);
  input.value = '';
});

The UI updates through observers and DOM mounts; the form only writes user input.

A Full Request Lifecycle

1. Player submits text.
2. Agent advances to a new timepoint.
3. `user-input` is written.
4. History and prompt timelines derive `messages-ready`.
5. LLM command is planned.
6. Executor streams chunks into `ai-response`.
7. Parser derives dialogue and status.
8. Renderers derive HTML.
9. DOM mounts update the page.

This flow stays inspectable because every intermediate value is a timeline frame.

Optional: Typewriter Effect

For a typewriter effect, observe the dialogue timeline and animate the visible text in the UI layer.

agent.observeTimeline('dialogue', frame => {
  typewriter.render(frame.value ?? '');
});

Keep animation outside the core graph. The timeline should hold the canonical value.

Summary

This example combines the full ChronoAI pattern:

  • timelines for state
  • reactions for parsing and rendering
  • commands for LLM calls
  • toolkit features for common infrastructure
  • DOM mounting for realtime UI

Next

Continue with Chapter 12: Snapshot and Persistence.