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, float
  • bool (“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

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


This site uses Just the Docs, a documentation theme for Jekyll.