The String-Based LLM Action Pattern: How to Make Any AI Model Execute Code Reliably

Adam Dugan • January 19, 2026

Every LLM provider has their own function calling API. OpenAI has one format. Anthropic has another. Google has a third. If you build on OpenAI's function calling and then want to switch to Claude or Gemini, you're rewriting your entire integration.

I built a different pattern for BalancingIQ that's embarrassingly simple: just have the LLM include special text codes in its response, detect those codes with string matching, and dispatch actions asynchronously.

This works with any LLM (OpenAI, Claude, Gemini, Llama, anything), never blocks the chat response, and gracefully handles failures. Here's how it works.

The Problem: Function Calling Is Provider-Specific

Most LLMs support "function calling" or "tool use" where the model can trigger predefined functions. Great in theory. In practice:

I needed to switch between OpenAI, Claude (via Bedrock), and Gemini based on cost and performance. Function calling APIs made this a nightmare. So I built something simpler.

The Solution: String-Based Action Codes

Instead of relying on provider-specific function calling, just tell the LLM to include special text codes in its response.

Step 1: Define Actions in Your System Prompt

Add instructions to your system prompt telling the LLM when to include action codes:

Python
SYSTEM_PROMPT = """
You are a financial advisory AI assistant for small businesses.

When the user asks you to send them something via email, include the text 
"send_email_user_action_code" somewhere in your response.

When the user asks for recent news about their industry, include the text 
"get_news_action_code" in your response.

Always respond naturally to the user. The action codes are internal signals 
and don't need to be explained to the user.
"""

That's it. No JSON schemas, no function definitions, just plain English instructions.

Step 2: Detect Actions with Simple String Matching

After getting the LLM's response, scan for action codes:

Python
def find_run_actions(bot_response, messages, system_prompt, user_email):
    """Detect and dispatch actions based on string codes in LLM response."""
    
    if "send_email_user_action_code" in bot_response:
        dispatch_action(
            action="send_email_user_action_code",
            bot_response=bot_response,
            messages=messages,
            system_prompt=system_prompt,
            user_email=user_email,
        )
    
    if "get_news_action_code" in bot_response:
        dispatch_action(
            action="get_news_action_code",
            # ... params
        )

Dead simple. No parsing, no validation, just substring matching. This works with any LLM because it's just text.

Step 3: Dispatch Actions Synchronously or Asynchronously (Fire-and-Forget)

The key insight: don't block the chat response waiting for actions to complete that don't need to be synchronous. Dispatch them synchronously or asynchronously and move on.

Python
import boto3
import json

lambda_client = boto3.client('lambda')
ACTION_DISPATCHER_FUNCTION = 'action-dispatcher-lambda'

def dispatch_action(action, *, bot_response, messages, system_prompt, user_email):
    """Fire-and-forget invoke; never block or crash caller."""
    try:
        payload = {
            "arguments": {
                "action": action,
                "bot_response": bot_response,
                "messages": messages,
                "system_prompt": system_prompt,
                "user_email": user_email,
            }
        }
        
        lambda_client.invoke(
            FunctionName=ACTION_DISPATCHER_FUNCTION,
            InvocationType="Event",  # async, fire-and-forget
            Payload=json.dumps(payload).encode("utf-8"),
        )
        print(f"Dispatched action: {action}")
    except Exception as e:
        # Log and move on; core chat response should not fail
        print(f"Dispatch failed for {action}: {e}")

InvocationType="Event" means the Lambda invocation returns immediately. The action runs in the background while your chat response goes back to the user.

Step 4: Strip Action Codes Before Returning to User

The action codes are internal signals. Remove them before showing the response to users:

Python
def handler(event, context):
    # Get LLM response
    bot_response = ask_chatbot(messages=messages, system_prompt=system_prompt)
    
    # Detect and dispatch actions
    find_run_actions(
        bot_response=bot_response,
        messages=messages,
        system_prompt=system_prompt,
        user_email=user_email
    )
    
    # Clean up response before returning to user
    clean_response = bot_response.replace("send_email_user_action_code", "")
    clean_response = clean_response.replace("get_news_action_code", "")
    
    return {
        "message": clean_response.strip()
    }

Step 5: Execute Actions in a Separate Lambda

The action dispatcher Lambda handles the actual execution:

Python
def handler(event, context):
    """Action dispatcher Lambda - handles background actions."""
    arguments = event.get('arguments', {})
    action = arguments.get('action', '')
    bot_response = arguments.get('bot_response', '')
    messages = arguments.get('messages', [])
    system_prompt = arguments.get('system_prompt', '')
    user_email = arguments.get('user_email', '')
    
    if action == 'send_email_user_action_code':
        send_email_action(
            bot_response=bot_response,
            messages=messages,
            system_prompt=system_prompt,
            recipient=user_email
        )
    
    elif action == 'get_news_action_code':
        fetch_news_action(messages=messages, user_email=user_email)
    
    return {"message": "Actions dispatched successfully"}

Real Example: Email Action

In BalancingIQ, when users ask for financial analysis via email, the system:

  1. LLM includes send_email_user_action_code in response
  2. Code is detected, action dispatched to background Lambda
  3. User gets immediate chat response (code stripped out)
  4. Background Lambda makes second LLM call to format email
  5. Email sent via Microsoft Graph API

Here's the email action handler:

Python
def send_email_action(bot_response, messages, system_prompt, recipient):
    """Generate and send formatted email based on conversation."""
    
    # Make a second LLM call with specialized prompt for email formatting
    email_prompt = """
    You are preparing a professional email based on the prior conversation.
    Return ONLY a JSON object with this structure:
    {
      "subject": "5-9 word subject line",
      "body": "Professional email body with 1-sentence summary, value, and next steps",
      "vega_spec": null or Vega-Lite chart spec if visualization was requested
    }
    
    Keep JSON under 5KB. Use double quotes. No markdown.
    """
    
    # Get structured email data from LLM
    email_response = ask_chatbot(messages=messages, system_prompt=email_prompt)
    
    # Parse JSON response
    email_data = json.loads(email_response)
    subject = email_data["subject"]
    body = email_data["body"]
    vega_spec = email_data.get("vega_spec")
    
    # Validate and send
    if subject and body:
        send_email_via_graph_api(
            recipient=recipient,
            subject=subject,
            body=body,
            chart=vega_spec
        )
        print(f"Email sent to {recipient}")

The action handler can make additional LLM calls, query databases, call external APIs, whatever it needs to do, all without blocking the original chat response.

Why This Pattern Works So Well

1. LLM-Agnostic: Works With Any Model

I can switch between OpenAI, Claude, and Gemini by changing one line:

Python
def ask_chatbot(messages, system_prompt):
    """Unified LLM interface - swap providers easily."""
    
    # Currently using Gemini
    return ask_gemini(messages, system_prompt)
    
    # Uncomment to switch to Claude
    # return ask_bedrock(messages, system_prompt)
    
    # Uncomment to switch to OpenAI
    # return ask_openai(messages, system_prompt)

The action detection doesn't care which LLM generated the response. It's just looking for substrings. This means:

2. Non-Blocking: Fast Response Times

Actions run in the background. The user gets their chat response immediately, even if the action takes 30 seconds to complete.

Example: Sending an email requires:

With fire-and-forget dispatch, the user's chat response comes back in <500ms. The email arrives 2-3 seconds later, which feels instant.

3. Fault-Tolerant: Actions Can Fail Safely

If an action fails (bad JSON, API timeout, whatever), the chat experience isn't broken:

Python
def dispatch_action(action, **kwargs):
    try:
        lambda_client.invoke(
            FunctionName=ACTION_DISPATCHER_FUNCTION,
            InvocationType="Event",
            Payload=json.dumps({"action": action, **kwargs})
        )
    except Exception as e:
        # Log the error but don't crash
        print(f"Action dispatch failed: {e}")
        # Could also send to error tracking service
        # sentry.capture_exception(e)

The user still gets their chat response. The action failure is logged, but doesn't break the conversation. You can monitor failures and fix them without users even noticing.

4. Easy to Debug and Test

No complex function calling protocols to debug. Just:

You can test actions independently by invoking the action dispatcher Lambda directly with mock payloads.

5. Extensible: Add Actions in Minutes

Adding a new action takes three steps:

  1. Update system prompt: "When the user asks for X, include 'new_action_code' in your response."
  2. Add detection: if "new_action_code" in bot_response: dispatch_action("new_action_code", ...)
  3. Implement handler: Add the action logic to the dispatcher Lambda

No schema definitions, no API contracts, just code.

6. Multiple Actions Just Work

Your system can naturally trigger multiple actions in a single response without any special handling:

LLM Response:

"I'll send you that cash flow analysis via email send_email_user_action_code and also pull recent news about your industry get_news_action_code so you have full context."

The action detector finds both codes and dispatches both actions simultaneously. No special "parallel function calling" mode needed, no complex response parsing, just multiple substring checks.

Comparison to official APIs:

In production, about 15% of BalancingIQ conversations trigger multiple actions (typically "send email" + "schedule followup" or "fetch data" + "generate report"). The system handles these as naturally as single actions, with no additional code complexity.

Comparison: String Codes vs Function Calling APIs

AspectString CodesFunction Calling
LLM SupportWorks with any LLMProvider-specific
Setup TimeMinutesHours (schemas, validation)
ReliabilityVery high (simple substring match)Variable (model-dependent)
BlockingNon-blocking (async dispatch)Often blocking (depends on impl)
DebuggingEasy (plain text logs)Complex (JSON validation)
Vendor Lock-inNoneHigh
ParametersOptional: Simple or ComplexComplex (typed schemas)

When to Use This Pattern

Great For:

Implementation Guide

Here's how to implement this in your system:

1. Define Your Actions

Python
ACTIONS = {
    "send_email_user_action_code": {
        "description": "Send formatted email to user",
        "handler": send_email_action,
    },
    "get_news_action_code": {
        "description": "Fetch recent industry news",
        "handler": fetch_news_action,
    },
    "schedule_followup_action_code": {
        "description": "Schedule follow-up task",
        "handler": schedule_followup_action,
    },
}

2. Update System Prompt

Python
action_instructions = "\n".join([
        f"- Include '{code}' when: {info['description']}"
        for code, info in ACTIONS.items()
    ])

    system_prompt = f"""
    You are a helpful AI assistant.

    Action Codes (internal use):
    {action_instructions}

    Always respond naturally to the user. Action codes are background signals.
    """

3. Create Action Detector

Python
def find_run_actions(bot_response, context):
    """Scan response for action codes and dispatch."""
    for action_code, action_info in ACTIONS.items():
        if action_code in bot_response:
            dispatch_action(
                action=action_code,
                context=context,
            )

4. Implement Async Dispatch

Python
def dispatch_action(action, context):
    """Fire-and-forget Lambda invocation."""
    try:
        lambda_client.invoke(
            FunctionName=os.environ['ACTION_DISPATCHER_LAMBDA'],
            InvocationType="Event",  # async
            Payload=json.dumps({
                "action": action,
                "context": context,
            }).encode("utf-8"),
        )
        print(f"Dispatched: {action}")
    except Exception as e:
        print(f"Dispatch failed: {action}, error: {e}")
        # Don't raise - keep chat working

5. Create Dispatcher Lambda

Python
def handler(event, context):
    """Action dispatcher - routes to appropriate handler."""
    action = event.get('action')
    context = event.get('context')
    
    if action in ACTIONS:
        handler_func = ACTIONS[action]['handler']
        handler_func(context)
    else:
        print(f"Unknown action: {action}")
    
    return {"status": "complete"}

Advanced: Multi-Step Actions

You can chain multiple LLM calls for complex actions:

Python
def send_email_action(context):
    """Multi-step action: analyze → format → send."""
    
    # Step 1: Analyze conversation for key points
    analysis_prompt = "Summarize the key points discussed."
    summary = ask_chatbot(context['messages'], analysis_prompt)
    
    # Step 2: Format as professional email
    email_prompt = f"Turn this into a professional email: {summary}"
    email_data = ask_chatbot(context['messages'], email_prompt)
    
    # Step 3: Parse and validate
    parsed = json.loads(email_data)
    
    # Step 4: Send via API
    send_email_via_api(
        recipient=context['user_email'],
        subject=parsed['subject'],
        body=parsed['body']
    )

Building LLM-powered actions? I'd love to hear about your implementation challenges, multi-provider systems, or action reliability issues. Reach out at adamdugan6@gmail.com or connect with me on LinkedIn.

The code examples are directly from my own production code bases. Not generated by LLMs. LLMs were used to help with research and article structure.