← Home

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. 1. The user types a natural-language request (“fetch me sales data for item #123”).
  2. 2. The app packages the request with a system prompt and sends both to an LLM endpoint.
  3. 3. The LLM returns an output containing structured tags or keywords defined by the system prompt.
  4. 4. The app parses the output for those tags and dispatches the matching functions.
  5. 5. The function results flow back into the chat — closing the loop.

Reference architecture

Usernatural languageAppchat surfaceLLMsystem prompt + msgParserextract tagsDispatchercall functionsfunction result → rendered back into the chatrequest →

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.

1. System prompt — defines the tag vocabularyts
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.
`;
2. Parser — extracts structured tags from the LLM outputts
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;
}
3. Dispatcher — routes each action to a real functionts
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
Full Chat-Driven App loopts
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"]
View project →

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"]
View project →

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"]
View project →