Custom Actions Guide
Extend Struckdown with custom Python functions that bypass the LLM.
Overview
Actions allow you to register Python functions that can be called from templates using [[@action:var|params]] syntax. This is useful for:
- RAG (Retrieval-Augmented Generation) – Search databases, vector stores, or APIs
- Data transformations – Format, validate, or process extracted data
- External integrations – Call APIs, query databases, read files
- Performance – Skip LLM calls for deterministic operations
Basic Example
Register an Action
from struckdown import Actions, LLMCredentials, complete
@Actions.register('uppercase')
def uppercase_text(context, text: str):
"""Convert text to uppercase"""
return text.upper()
# Use in template with literal string (quoted)
result = complete('[[@uppercase:loud|text="hello world"]]')
print(result['loud']) # "HELLO WORLD"
Template Syntax
Actions use [[@action:var|params]] instead of [[type:var]]:
# LLM completion (calls AI)
[[pick:color|red,blue,green]]
# Custom action (calls Python function) - literal value quoted
[[@uppercase:result|text="hello"]]
Action Parameters
Parameter Passing
Parameters are passed as key=value pairs:
@Actions.register('greet')
def greet(context, name: str, greeting: str = "Hello"):
"""Greet someone"""
return f"{greeting}, {name}!"
# Use it with literal values (quoted strings)
complete('[[@greet:message|name="Alice",greeting="Hi"]]')
# Output: "Hi, Alice!"
Using Context Variables
Reference previous extractions by using unquoted values:
template = """
Extract name: [[name]]
<checkpoint>
Greet them: [[@greet:greeting|name=name]]
"""
# a template with LLM slots needs credentials; action-only templates do not
result = complete(
template, context={"input": "My name is Bob"}, credentials=LLMCredentials.from_env()
)
print(result['greeting']) # "Hello, Bob!"
Variable vs Literal Syntax
The syntax distinguishes between variable references and literal values:
| Syntax | Meaning | Example |
|---|---|---|
key=varname | Variable reference | query=topic looks up topic in context |
key="literal" | Literal string | query="hello" passes the string “hello” |
key=123 | Literal number | n=5 passes the number 5 |
# Variable reference - looks up 'extracted_query' in context
[[@search:results|query=extracted_query,n=5]]
# Literal value - passes the string "hello world" directly
[[@search:results|query="hello world",n=5]]
For positional arguments in actions, unquoted values are also variable references:
# Positional variable reference - looks up 'topic' in context
[[@evidence|topic]]
# Positional literal - passes the string "climate change" directly
[[@evidence|"climate change"]]
Type Coercion
Struckdown automatically converts string parameters to the correct type based on function signature:
@Actions.register('multiply')
def multiply(context, value: int, factor: int = 2):
"""Multiply value by factor"""
return str(value * factor)
# String "10" is automatically converted to int 10
complete("[[@multiply:result|value=10,factor=5]]")
# Output: "50"
Supported types:
str(default)int,floatbool(“true”/”false” converted)
Coercion uses pydantic’s lax mode, which does not parse JSON strings, so a List[str] or dict parameter receives the raw string. If any one parameter fails coercion, every parameter is passed through uncoerced, as strings.
Accessing Context
The context parameter provides access to all previously extracted variables:
@Actions.register('count_extractions')
def count_extractions(context):
"""Count how many variables have been extracted"""
return f"Extracted {len(context)} variables: {', '.join(context.keys())}"
template = """
Name: [[name]]
Age: [[int:age]]
<checkpoint>
Summary: [[@count_extractions:summary]]
"""
Error Handling
Control how errors are handled with the on_error parameter:
Propagate (Default)
Raises exceptions immediately:
@Actions.register('strict_action', on_error='propagate')
def strict_action(context):
raise ValueError("Something went wrong")
# This will raise ValueError
Return Empty
Returns empty string on error:
@Actions.register('safe_action', on_error='return_empty')
def safe_action(context, url: str):
try:
return fetch_data(url)
except Exception:
raise # Will be caught and return ""
# If fetch fails, continues with empty string
Return Default
Returns custom default value on error:
@Actions.register('search_docs', on_error='return_default', default='No documentation found')
def search_docs(context, query: str):
"""Search documentation database"""
# If this fails, returns "No documentation found"
return database.search(query)
@Actions.register('get_count', on_error='return_default', default='0')
def get_count(context, category: str):
"""Get item count from database"""
# If this fails, returns "0"
return str(len(database.query(category)))
Return Types
Specify a Pydantic model type for automatic deserialization from JSON:
from pydantic import BaseModel
class SearchResults(BaseModel):
items: list[str]
count: int
@Actions.register('search', return_type=SearchResults)
def search(context, query: str):
return SearchResults(items=['a', 'b'], count=2)
Real-World Examples
RAG with Vector Search
from struckdown import Actions, LLMCredentials, complete
import chromadb
# Initialize your vector database
db = chromadb.Client()
collection = db.get_or_create_collection("docs")
@Actions.register('search_docs', on_error='return_empty')
def search_docs(context, query: str, n: int = 3):
"""Search documentation using vector similarity"""
results = collection.query(
query_texts=[query],
n_results=n
)
# Format results
docs = results['documents'][0]
return "\n\n".join(f"- {doc}" for doc in docs)
# Use in template
template = """
User question:
Relevant docs:
[[@search_docs:context|query=question,n=5]]
<checkpoint>
Based on this context:
Answer the question:
[[answer]]
"""
result = complete(
template,
context={"question": "How do I use actions?"},
credentials=LLMCredentials.from_env(),
)
Database Query
import sqlite3
@Actions.register('query_users', on_error='return_empty')
def query_users(context, email: str):
"""Look up user by email"""
conn = sqlite3.connect('users.db')
cursor = conn.execute(
"SELECT name, role FROM users WHERE email = ?",
(email,)
)
row = cursor.fetchone()
conn.close()
if row:
return f"Name: {row[0]}, Role: {row[1]}"
return "User not found"
# Use it
template = """
Email from logs: [[extract:email]]
<checkpoint>
User info: [[@query_users:user|email=email]]
Personalized response for : [[response]]
"""
API Integration
import requests
@Actions.register('weather', on_error='return_empty')
def get_weather(context, city: str, units: str = "metric"):
"""Fetch current weather"""
api_key = os.getenv("WEATHER_API_KEY")
response = requests.get(
f"https://api.openweathermap.org/data/2.5/weather",
params={"q": city, "appid": api_key, "units": units}
)
response.raise_for_status()
data = response.json()
temp = data['main']['temp']
desc = data['weather'][0]['description']
return f"Temperature: {temp}°C, Conditions: {desc}"
# Use it
template = """
Extract city: [[city]]
<checkpoint>
Weather: [[@weather:conditions|city=city]]
Travel advice for given : [[advice]]
"""
Data Transformation
from datetime import datetime
@Actions.register('format_date')
def format_date(context, iso_date: str, format: str = "%B %d, %Y"):
"""Convert ISO date to readable format"""
dt = datetime.fromisoformat(iso_date)
return dt.strftime(format)
@Actions.register('calculate_age')
def calculate_age(context, birth_date: str):
"""Calculate age from birth date"""
birth = datetime.fromisoformat(birth_date)
today = datetime.now()
age = today.year - birth.year
if (today.month, today.day) < (birth.month, birth.day):
age -= 1
return str(age)
# Use them
template = """
Extract birth date (ISO format): [[date:birth]]
<checkpoint>
Birth date: [[@format_date:formatted|iso_date=birth,format="%d/%m/%Y"]]
Age: [[@calculate_age:age|birth_date=birth]]
Birthday message for someone aged : [[message]]
"""
Best Practices
1. Type Hints
Always use type hints for automatic parameter validation:
# Good
@Actions.register('add')
def add(context, a: int, b: int):
return str(a + b)
# Bad (parameters are strings)
@Actions.register('add')
def add(context, a, b):
return str(int(a) + int(b)) # Manual conversion
2. Return Strings Where the Value Goes into the Prompt
An action may return any value; it becomes the slot’s output. A string is inserted into the prompt as it is, so return a string when the value is meant to be read by the model:
@Actions.register('count')
def count(context, text: str):
return f"{len(text.split())} words"
3. Error Handling
Use on_error='return_empty' for non-critical operations:
# Non-critical -- if fetch fails, continue anyway
@Actions.register('fetch_metadata', on_error='return_empty')
def fetch_metadata(context, url: str):
return requests.get(url).json()
# Critical -- if validation fails, stop immediately
@Actions.register('validate_license', on_error='propagate')
def validate_license(context, key: str):
if not is_valid(key):
raise ValueError("Invalid license")
return "Valid"
4. Descriptive Names
Use clear, verb-based names:
# Good
@Actions.register('search_documents')
@Actions.register('calculate_score')
@Actions.register('format_currency')
# Bad
@Actions.register('docs')
@Actions.register('score')
@Actions.register('money')
5. Documentation
Add docstrings – they help users understand your actions:
@Actions.register('search_products')
def search_products(context, query: str, limit: int = 10):
"""
Search product database using fuzzy matching.
Args:
query: Search term
limit: Maximum results to return (default: 10)
Returns:
Formatted list of products with prices
"""
# implementation
Listing Registered Actions
Check which actions are available:
from struckdown import Actions
# List all registered actions
print(Actions.list_registered())
# Check if specific action exists
if Actions.is_registered('search_docs'):
print("Search action is available")
# Get return type for an action
return_type = Actions.get_return_type('search_docs')
See Also
- Getting Started – Learn Struckdown basics
- Template Syntax – Complete syntax reference
- Actions Reference – Actions API reference