Essay · Design Pattern
Chat-Driven Apps
Software where the chat surface is the primary control plane, and the LLM's outputs — parsed for structured tokens — drive what the application actually does.
What it is
A Chat-Driven App (CDA) is an application where the user's primary way of doing things is to talk to a chat surface backed by an LLM. The model's responses aren't just text — they contain structured instructions the app parses out and executes as real function calls. The chat becomes the steering wheel; the model becomes the translator between human intent and application behavior.
What it isn't
- ×Not a chatbot. A chatbot answers; a CDA acts.
- ×Not a copilot sidebar. A copilot suggests; a CDA executes against app state.
- ×Not a Q&A widget. Q&A is read-only; a CDA writes, fetches, renders, and orchestrates.
- ×Not raw tool-calling. Tool-calling is the plumbing; CDA is the product design philosophy that puts the chat at the center.
The pattern, in five steps
- 1. The user types a natural-language request (“fetch me sales data for item #123”).
- 2. The app packages the request with a system prompt and sends both to an LLM endpoint.
- 3. The LLM returns an output containing structured tags or keywords defined by the system prompt.
- 4. The app parses the output for those tags and dispatches the matching functions.
- 5. The function results flow back into the chat — closing the loop.
Reference architecture
Worked example
User message: “fetch me sales data for item #123.” Here's the minimal version of the three moving parts. The full implementation is below.
class=class="text-emerald-300">"text-zinc-500 italic">// The system prompt tells the LLM the tag vocabulary the app understands.
const SYSTEM_PROMPT = class="text-emerald-300">`
You are an assistant for an internal sales tool.
When the user asks for data, respond with a short natural-language
acknowledgement followed by a structured action block:
[ACTION:fetch_sales_data item_id="<id>"]
[ACTION:render_chart type="<line|bar>" target="results"]
You may emit zero or more actions per turn. The app will execute them.
`;class=class="text-emerald-300">"text-zinc-500 italic">// 2. Parse: extract [ACTION:name key=class="text-emerald-300">"value" ...] tags from the LLM output.
type Action = { name: string; args: Record<string, string> };
function parseActions(text: string): Action[] {
const re = /\[ACTION:(\w+)([^\]]*)\]/g;
const out: Action[] = [];
for (const match of text.matchAll(re)) {
const args: Record<string, string> = {};
for (const kv of match[2].matchAll(/(\w+)=class="text-emerald-300">"([^"]*)"/g)) {
args[kv[1]] = kv[2];
}
out.push({ name: match[1], args });
}
return out;
}class=class="text-emerald-300">"text-zinc-500 italic">// 3. Dispatch: route each parsed action to a registered function.
const handlers = {
fetch_sales_data: (a) => fetchSalesData(a.item_id),
render_chart: (a) => renderChart(a.type, a.target),
};
async function dispatch(actions: Action[]) {
for (const action of actions) {
const fn = handlers[action.name];
if (fn) await fn(action.args);
}
}▸ See full end-to-end implementation
class=class="text-emerald-300">"text-zinc-500 italic">// ---------------------------------------------------------------
class=class="text-emerald-300">"text-zinc-500 italic">// Full Chat-Driven App loop: user message → LLM → parse → dispatch
class=class="text-emerald-300">"text-zinc-500 italic">// ---------------------------------------------------------------
const SYSTEM_PROMPT = class="text-emerald-300">`
You are an assistant for an internal sales tool.
Emit structured actions like:
[ACTION:fetch_sales_data item_id="<id>"]
[ACTION:render_chart type="<line|bar>" target="results"]
The app will execute every action you emit.
`;
type Action = { name: string; args: Record<string, string> };
class=class="text-emerald-300">"text-zinc-500 italic">// 1. Call the LLM with the system prompt + user message.
async function callLLM(userMessage: string): Promise<string> {
const res = await fetch(class="text-emerald-300">"https:class="text-zinc-500 italicclass="text-emerald-300">">//api.example.com/v1/chat/completions", {
method: class="text-emerald-300">"POST",
headers: { class="text-emerald-300">"Content-Type": class="text-emerald-300">"application/json" },
body: JSON.stringify({
model: class="text-emerald-300">"gpt-4o-mini",
messages: [
{ role: class="text-emerald-300">"system", content: SYSTEM_PROMPT },
{ role: class="text-emerald-300">"user", content: userMessage },
],
}),
});
const json = await res.json();
return json.choices[0].message.content as string;
}
class=class="text-emerald-300">"text-zinc-500 italic">// 2. Parse: pull [ACTION:name key=class="text-emerald-300">"value" ...] tags out of the LLM output.
function parseActions(text: string): Action[] {
const re = /\[ACTION:(\w+)([^\]]*)\]/g;
const out: Action[] = [];
for (const match of text.matchAll(re)) {
const args: Record<string, string> = {};
for (const kv of match[2].matchAll(/(\w+)=class="text-emerald-300">"([^"]*)class="text-emerald-300">"/g)) {
args[kv[1]] = kv[2];
}
out.push({ name: match[1], args });
}
return out;
}
class="text-zinc-500 italicclass="text-emerald-300">">// 3. Dispatch: each action maps to a real app function.
const handlers: Record<string, (a: any) => Promise<any>> = {
fetch_sales_data: async (a) => {
const rows = await db.query("SELECT * FROM sales WHERE item_id = $1class="text-emerald-300">", [a.item_id]);
return rows;
},
render_chart: async (a) => {
ui.renderChart({ type: a.type, target: a.target });
},
};
async function dispatch(actions: Action[]) {
const results = [];
for (const action of actions) {
const fn = handlers[action.name];
if (!fn) continue;
results.push({ action: action.name, result: await fn(action.args) });
}
return results;
}
class="text-zinc-500 italicclass="text-emerald-300">">// 4. The full loop the chat surface invokes on every user turn.
export async function handleUserTurn(userMessage: string) {
const llmOutput = await callLLM(userMessage);
const actions = parseActions(llmOutput);
const results = await dispatch(actions);
return {
visibleText: llmOutput.replace(/\[ACTION:[^\]]*\]/g, "class="text-emerald-300">").trim(),
actions,
results,
};
}
class="text-zinc-500 italicclass="text-emerald-300">">// Example:
class="text-zinc-500 italicclass="text-emerald-300">">// await handleUserTurn("fetch me sales data for item #123class="text-emerald-300">");
class="text-zinc-500 italicclass="text-emerald-300">">//
class="text-zinc-500 italicclass="text-emerald-300">">// LLM returns:
class="text-zinc-500 italicclass="text-emerald-300">">// "Got it — pulling sales for item 123.
class=class="text-emerald-300">"text-zinc-500 italic">// [ACTION:fetch_sales_data item_id=\class="text-emerald-300">"123\"]
class=class="text-emerald-300">"text-zinc-500 italic">// [ACTION:render_chart type=\class="text-emerald-300">"line\" target=\class="text-emerald-300">"results\"]class="text-emerald-300">"
class="text-zinc-500 italicclass="text-emerald-300">">//
class="text-zinc-500 italicclass="text-emerald-300">">// The parser produces two Action objects, the dispatcher runs them,
class="text-zinc-500 italic">// and the chat renders the cleaned text plus the real app side effects.Why it works
Composable
New capabilities = new handler + new tag in the system prompt. No UI redesign required.
Observable
Every action the LLM took is in the parsed output and the log. No black-box reasoning.
Debuggable
When something goes wrong it’s the prompt, the parse, or the dispatch — three discrete places to look.
Model-agnostic
The pattern doesn’t care if the model is OpenAI, Anthropic, a local LM Studio endpoint, or Grok.
Three CDAs I've built
Each of these is a real product using the same pattern. Click through for the full project write-up.
BatonBot / LocalChainAI
BatonBot uses the Chat-Driven App pattern to turn messy human input into structured pipeline jobs. One of its features: a sanitizer agent that cleans up a long, freeform todo list into discrete tasks the orchestrator can dispatch.
User input
Here’s my dump of stuff to do today, clean it up and run it: fix login bug. also the readme is wrong. write tests for the parser maybe. deploy preview???
LLM output
[ACTION:enqueue_task title="Fix login bug" priority="high"] [ACTION:enqueue_task title="Correct README inaccuracies" priority="medium"] [ACTION:enqueue_task title="Write unit tests for parser module" priority="medium"] [ACTION:enqueue_task title="Deploy preview environment" priority="low"]
SpeechXP
SpeechXP uses the Chat-Driven App pattern to grade real-time dialogue practice. The LLM scores the user’s reply to a simulated high-pressure scenario and emits structured outputs the app turns into a graded dashboard and formatted feedback.
User input
Scenario: explain a missed project deadline to a frustrated client. User reply: "I want to be straight with you — we slipped because of an unexpected dependency change. Here’s the new plan and the date I’ll commit to."
LLM output
Strong, accountable opening. Watch the pace on the recovery plan. [ACTION:show_grade clarity="8" tone="7" confidence="9" relevance="9"] [ACTION:format_feedback section="strengths" body="Owned the slip, offered a concrete recovery."] [ACTION:format_feedback section="growth" body="Slow down when delivering the new commitment date."] [ACTION:advance_to next_scenario="2"]
HBAR.io
HBAR.io uses the Chat-Driven App pattern to turn plain-language requests into Hedera (HBAR) workflows. The chat replaces a multi-step wizard UI — the model translates English into a structured operations pipeline that runs against the Hedera SDK.
User input
Create 50 wallets and chain a 0.1 HBAR transfer from each into a single treasury wallet.
LLM output
[ACTION:create_wallets count="50" label_prefix="batch_"] [ACTION:create_wallet label="treasury"] [ACTION:chain_transfer from="batch_*" to="treasury" amount="0.1" memo="batch sweep"] [ACTION:report summary="true"]