Extend Struckdown with custom Python functions that bypass the LLM.
Actions allow you to register Python functions that can be called from templates using [[@action:var|params]] syntax. This is useful for:
from struckdown import Actions, chatter
@Actions.register('uppercase')
def uppercase_text(context, text: str):
"""Convert text to uppercase"""
return text.upper()
# Use in template with literal string (quoted)
result = chatter('[[@uppercase:loud|text="hello world"]]')
print(result['loud']) # "HELLO WORLD"
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"]]
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)
chatter('[[@greet:message|name="Alice",greeting="Hi"]]')
# Output: "Hi, Alice!"
Reference previous extractions by using unquoted values:
template = """
Extract name: [[name]]
<checkpoint>
Greet them: [[@greet:greeting|name=name]]
"""
result = chatter(template, context={"input": "My name is Bob"})
print(result['greeting']) # "Hello, Bob!"
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"]]
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
chatter("[[@multiply:result|value=10,factor=5]]")
# Output: "50"
Supported types:
str (default)int, floatbool (“true”/”false” converted)List[T], Dict[str, T] (JSON parsing)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]]
"""
Control how errors are handled with the on_error parameter:
Raises exceptions immediately:
@Actions.register('strict_action', on_error='propagate')
def strict_action(context):
raise ValueError("Something went wrong")
# This will raise ValueError
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
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)))
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)
from struckdown import Actions, chatter
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 = chatter(template, context={"question": "How do I use actions?"})
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]]
"""
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]]
"""
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]]
"""
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
Actions should always return strings (they’re inserted into templates):
# Good
@Actions.register('count')
def count(context, items: List[str]):
return str(len(items))
# Bad (returns int, will cause errors)
@Actions.register('count')
def count(context, items: List[str]):
return len(items)
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"
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')
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
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')