Adding Conversational AI to Your App — Part 1: Converting Natural Language into JSON Actions

by
Tags:
Category: ,

What We’re Building

When I recently paired with Claude Code to build a text-based to-do app (code available on Github, disclaimer: mostly generated by Claude), I wanted to add a simple natural language interface. In this post, we’ll build that feature using TypeScript, Node, and an OpenAI model, without using any special frameworks that might get in the way of learning.  We’ll start with the gpt-3.5-turbo model. It’s not the most powerful model, but it’s cost-effective.

In future posts, we’ll look at adding conversational capabilities and comparing the UX trade-offs between chat-based and graphical interfaces.

Why Natural Language Matters

Based on my experience with services like ChatGPT and Claude, and tools like Cursor and Claude Code, I’ve come to appreciate how much I can get done in a single session with very little context switching. I can turn a thought into action on a computer—just by using natural language.

The Problem with Slash Commands

Our current to-do app accepts slash commands like /add, /edit, /delete, and /complete, each with its own arguments. A /help command lists them all, but you still have to remember the command and its arguments (there’s no autocomplete implemented). That’s fine if I’m writing code, but it’s a little clunky when I’m just trying to jot down to-dos.

Examples of How I’d Like to Work

I’d prefer to enter in sentences like below:

  • “I need to buy groceries, call the dentist, and finish the report by Friday.”
  • “High priority tasks: fix the bug in the login system and deploy to production.”
  • “Add call doctor next Monday.”
  • “Create shopping list.”
  • “Tomorrow, I need to pick up the dry cleaning.”
  • “I finished the first task.”
  • “I’m done buying the groceries.”
A recording of how to use slash commands and natural language in the to-do app
Using slash commands and natural language

From Natural Language to JSON (a tiny DSL)

Each of these natural-language requests should map to one or more deterministic operations like add, complete, edit, or create_list. This is where LLMs shine—they can translate natural language into structured output. Since code works best with structured data, we’ll have the LLM return a JSON object describing the action and any parameters. JSON is a good choice because it’s human readable, supported by most languages and frameworks, and LLMs have been heavily trained on it.

Here’s the JSON description generated by Claude Code, for an add_todo command (this is not a JSON Schema document, just a description):

{
  "action": "add_todo",
  "title": "string (required)",
  "description": "string (optional)",
  "priority": "high|medium|low (optional)",
  "dueDate": "YYYY-MM-DD format (optional)",
  "categories": ["array of strings (optional)"]
}

At this point, we’ve defined a tiny domain-specific language (DSL) for our app. Given this, we can instruct an LLM to take a natural-language request and return the corresponding JSON. 

The System Prompt: The Secret Sauce

The next step is to build a comprehensive set of instructions—our system prompt—that can handle the examples above. It needs to know today’s date (for resolving time expressions), the current and available todo lists, the available commands, the JSON structure, and a set of examples.

Here’s the prompt generated by Claude Code (after a couple of iterations)—this is the secret sauce of the feature:

System Prompt (link to code):

const systemPrompt =
`You are a helpful assistant that parses natural language todo requests into structured JSON.


Current context:
- Current list: ${context.currentList?.name || 'None'}
- Available lists: ${context.availableLists?.map((l: any) => l.name).join(', ') || 'None'}
- Today's date: ${new Date().toISOString().split('T')[0]}


Parse the user's input and determine what action they want to take. Respond with valid JSON only.


Available actions:
- "add_todo": Add a single todo item
- "add_multiple_todos": Add multiple todo items from a paragraph or list
- "list_todos": Show todos (can be filtered)
- "complete_todo": Mark a todo as completed 
- "edit_todo": Modify an existing todo
- "create_list": Create a new todo list
- "switch_list": Switch to a different list
- "unknown": Unable to parse the request


JSON Schema for add_todo:
{
 "action": "add_todo",
 "title": "string (required)",
 "description": "string (optional)",
 "priority": "high|medium|low (optional)",
 "dueDate": "YYYY-MM-DD format (optional)",
 "categories": ["array of strings (optional)"]
}


JSON Schema for add_multiple_todos:
{
 "action": "add_multiple_todos",
 "todos": [
   {
     "title": "string (required)",
     "description": "string (optional)",
     "priority": "high|medium|low (optional)",
     "dueDate": "YYYY-MM-DD format (optional)",
     "categories": ["array of strings (optional)"]
   }
 ]
}


Date parsing guidelines:
- "today" -> use today's date
- "tomorrow" -> add 1 day to today's date
- "next week" -> add 7 days to today's date
- "Monday", "Tuesday", etc. -> find the next occurrence of that day
- "next Monday" -> find the Monday after next Monday
- "in 3 days" -> add 3 days to today's date
- "2024-01-15" -> use as-is in YYYY-MM-DD format


Examples:
"Add buy groceries with high priority" -> {"action": "add_todo", "title": "buy groceries", "priority": "high"}
"Add get milk tomorrow" -> {"action": "add_todo", "title": "get milk", "dueDate": "${new Date(Date.now() + 24*60*60*1000).toISOString().split('T')[0]}"}
"Add call doctor next Monday" -> {"action": "add_todo", "title": "call doctor", "dueDate": "2024-XX-XX"}
"Add finish project with high priority due Friday" -> {"action": "add_todo", "title": "finish project", "priority": "high", "dueDate": "2024-XX-XX"}


Multiple todos examples:
"I need to buy groceries, call the dentist, and finish the report by Friday" -> {"action": "add_multiple_todos", "todos": [{"title": "buy groceries"}, {"title": "call the dentist"}, {"title": "finish the report", "dueDate": "2025-XX-XX"}]}
"Add these tasks: 1) review code 2) update documentation 3) send email to team" -> {"action": "add_multiple_todos", "todos": [{"title": "review code"}, {"title": "update documentation"}, {"title": "send email to team"}]}
"Tomorrow I need to pick up dry cleaning, go to the bank, and schedule a haircut" -> {"action": "add_multiple_todos", "todos": [{"title": "pick up dry cleaning", "dueDate": "${new Date(Date.now() + 24*60*60*1000).toISOString().split('T')[0]}"}, {"title": "go to the bank", "dueDate": "${new Date(Date.now() + 24*60*60*1000).toISOString().split('T')[0]}"}, {"title": "schedule a haircut", "dueDate": "${new Date(Date.now() + 24*60*60*1000).toISOString().split('T')[0]}"}]}
"High priority tasks: fix the bug in login system and deploy to production" -> {"action": "add_multiple_todos", "todos": [{"title": "fix the bug in login system", "priority": "high"}, {"title": "deploy to production", "priority": "high"}]}


Other examples:
"Show me my work todos" -> {"action": "list_todos", "filter": {"category": "work"}}
"Complete the first task" -> {"action": "complete_todo", "todoNumber": 1}
"Create shopping list" -> {"action": "create_list", "name": "shopping"}
"Switch to work list" -> {"action": "switch_list", "name": "work"}`;
  }

Below is where we send a request to the LLM and deserialize the JSON response (link to code)

async parseNaturalLanguage(input: string, context: any): Promise<any> {
   if (!this.client) {
     throw new Error('OpenAI client not configured. Please set OPENAI_API_KEY environment variable.');
   }
   
   const systemPrompt = this.buildSystemPrompt(context);


   try {
     const response = await this.client.chat.completions.create({
       model: 'gpt-3.5-turbo',
       messages: [
         { role: 'system', content: systemPrompt },
         { role: 'user', content: input }
       ],
       max_tokens: 200,
       temperature: 0.1 // Low temperature for consistent parsing
     });


     const content = response.choices[0]?.message?.content;
     if (!content) {
       throw new Error('No response from OpenAI');
     }


     return JSON.parse(content);
   } catch (error) {
     console.error('OpenAI parsing error:', error);
     // Fallback to unknown action
     return {
       action: 'unknown',
       originalInput: input,
       error: error instanceof Error ? error.message : 'Unknown error'
     };
   }
 }

Command Routing

Now that we can produce a JSON object from our English request, we need to carry out the action. For now, this is as simple as a “switch based” command router (link to code):

// Function to handle AI-parsed commands
 function handleParsedCommand(parsed: any): Promise<void> {
   switch (parsed.action) {
     case 'add_todo':
       handleAddTodo(parsed);
       break;
     case 'add_multiple_todos':
       handleAddMultipleTodos(parsed);
       break;
     case 'list_todos':
       handleListTodos(parsed);
       break;
     case 'complete_todo':
       handleCompleteTodo(parsed);
       break;
     case 'edit_todo':
       handleEditTodo(parsed);
       break;
     case 'create_list':
       handleCreateList(parsed);
       break;
     case 'switch_list':
       handleSwitchList(parsed);
       break;
     case 'unknown':
     default:
       console.log(`❓ I couldn't understand your request: "${parsed.originalInput || 'unknown'}"`);
       if (parsed.error) {
         console.log(`   Error: ${parsed.error}`);
       }
       console.log('💡 Try rephrasing or use a slash command like /add, /list, etc.\n');
       break;
   }
 }

Tool Calling, MCP, and Our Simpler Alternative

Translating requests into JSON actions is a common pattern, used by major players like OpenAI and Anthropic. It’s the same idea behind “tool calling” and MCP servers—both use JSON to define a function call (MCP, specifically, uses JSON-RPC to serialize calls over the wire).

Most LLM providers now support these approaches with JSON schemas for functions. Their models are trained to emit function calls reliably, and the APIs automatically include the function schema in the background—making prompts simpler. This is great for integrating existing APIs. 

In our case, a lightweight, custom approach was enough: define a clear DSL, guide the model with examples, and route the parsed output to deterministic handlers. Start small, get the structure right, and you can add complexity later—whether that’s conversational UX or full-blown agents.

Using JSON Mode

I initially used gpt-3.5-turbo because that’s what Claude Code generated in the code, and it worked well for my purposes. I also (mistakenly) thought it was one of the cheapest options. In reality, gpt-4o-mini is both cheaper (as of this writing, $0.15 per 1M input tokens vs. $0.50 for gpt-3.5-turbo) and advertised as more powerful, so I switched to it.

Unfortunately, with gpt-4o-mini, the responses come back as invalid JSON because the model wraps the output in a Markdown code block (```json```). While you can strip the code-block markers after the fact, in other projects I’ve avoided this problem by enabling JSON mode. This ensures that the response is well-formed JSON.

Our current setup isn’t using JSON mode. We’re just providing system-prompt instructions to return JSON—which seemed to work fine for gpt-3.5-turbo. To enable this mode, we must add the response_format parameter, with a value of "json_object":

     const response = await this.client.chat.completions.create({
       model: 'gpt-4o-mini',
       messages: [
         { role: 'system', content: systemPrompt },
         { role: 'user', content: input }
       ],
       max_tokens: 200,
       temperature: 0.1 // Low temperature for consistency,
       response_format: {"type": "json_object"},
     });

Please note that OpenAI actually recommends a better approach – using JSON Schema where possible (for any model starting with gpt-4o-mini or newer), to ensure the response is not only well-formed JSON, but adheres to a schema you provide. I’ve used this on my other projects, so in the next post, we’ll update the code to use JSON Schema.

Lessons from Building This Feature

While the JSON translation pattern is straightforward in concept, implementing it taught me a few practical lessons. These are things that worked well for me, and that we value at Chariot when building AI-driven tools:

  • Debugging in the open – During development, printing out the system prompt, LLM response, and successfully parsed JSON object was invaluable for spotting issues early.
  • Transparency for the user – At Chariot, we believe users should always know what’s going to and from AI services. This app includes a /prompt command so they can inspect the exact system prompt being sent.
  • Start simple and deterministic – Deterministic workflows are the best starting point; you can always layer on agent-like behaviors later.
  • Iterate on prompts – Good prompt design requires giving the model complete, accurate context.
  • Optimize for “thought-to-execution” speed – The real UX win of a natural language interface is how quickly an idea becomes a reliable action, behaving as expected by the user.
  • Plan for graceful degradation – Have a fallback when the AI is unavailable or when users prefer manual commands.
  • Keep an eye on the latest models and pricing – the LLM landscape is constantly evolving. What was state of the art today, may be under-performing and more expensive next week.