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:
- Different APIs: OpenAI uses
functions, Anthropic usestools, Google usesfunction_declarations - Different formats: Parameter schemas, response structures, all different
- Different reliability: Some models are better at function calling than others
- Vendor lock-in: If you build on one provider's API, switching is painful
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:
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:
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.
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:
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:
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:
- LLM includes
send_email_user_action_codein response - Code is detected, action dispatched to background Lambda
- User gets immediate chat response (code stripped out)
- Background Lambda makes second LLM call to format email
- Email sent via Microsoft Graph API
Here's the email action handler:
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:
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:
- No vendor lock-in
- Easy A/B testing between models
- Route different queries to different models
- Graceful degradation if one provider has an outage
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:
- Second LLM call (~1-2 seconds)
- JSON parsing and validation (~50ms)
- Microsoft Graph API call (~500ms)
- Total: ~2.5 seconds
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:
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:
- Check the LLM response: does it contain the action code?
- Check dispatch logs: was the action dispatched?
- Check action handler logs: did the action execute?
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:
- Update system prompt: "When the user asks for X, include 'new_action_code' in your response."
- Add detection:
if "new_action_code" in bot_response: dispatch_action("new_action_code", ...) - 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:
- OpenAI: Requires enabling parallel function calling, parsing array of tool_calls
- Claude: Can return multiple tool blocks, requires handling each with structured iteration
- Gemini: Similar structured parsing needed for parallel function declarations
- Your string codes: Same simple code handles 1 action or 10 actions identically
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
| Aspect | String Codes | Function Calling |
|---|---|---|
| LLM Support | Works with any LLM | Provider-specific |
| Setup Time | Minutes | Hours (schemas, validation) |
| Reliability | Very high (simple substring match) | Variable (model-dependent) |
| Blocking | Non-blocking (async dispatch) | Often blocking (depends on impl) |
| Debugging | Easy (plain text logs) | Complex (JSON validation) |
| Vendor Lock-in | None | High |
| Parameters | Optional: Simple or Complex | Complex (typed schemas) |
When to Use This Pattern
Great For:
- Multi-provider systems: You want to switch between LLMs easily
- Async actions: Actions can happen in the background (email, notifications, logging)
- Simple triggers: "Send email," "fetch news," "schedule task"
- Rapid prototyping: Add actions in minutes, not hours
- Cost optimization: Route to cheapest model that can handle string codes
Implementation Guide
Here's how to implement this in your system:
1. Define Your Actions
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
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
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
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 working5. Create Dispatcher Lambda
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:
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.