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:
| Timeline | Purpose |
|---|---|
user-input | Player input |
messages-ready | LLM message history |
ai-response | Raw streamed model output |
parsed-output | Parsed structured result |
dialogue | Assistant dialogue text |
status | Character state object |
dialogue-html | Rendered dialogue |
status-html | Rendered 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.