Actions Reference
Actions are Python functions called from templates with [[@action:var|params]] syntax. They run instead of an LLM call.
Syntax
[[@action:variable]] # No parameters
[[@action:variable|param=value]] # Named parameter
[[@action:variable|param1=v1,param2=v2]] # Multiple parameters
[[@action:variable|positional_arg]] # Positional parameter
[[@action]] # Result stored under the action's name
Positional parameters fill the function’s parameters in order, skipping context.
Parameter Types
| Syntax | Type | Description |
|---|---|---|
param=varname | Variable | Looks up varname in the context |
param="literal" | String | Literal string; double quotes only |
param=123, param=1.5 | Number | Literal number |
varname (positional) | Variable | An unquoted or quoted identifier is looked up in the context |
"two words" (positional) | String | A quoted value that is not an identifier stays literal |
Any unquoted identifier is treated as a variable name. If the name is not in the context, struckdown logs a warning and passes the name itself as a string. So raw=true passes the string "true" (with a warning), which a bool parameter then coerces to True; see Type Coercion.
Single-quoted values do not parse.
Examples
# Variable reference - looks up 'topic' in context
[[@search:results|query=topic]]
# Literal string
[[@search:results|query="climate change"]]
# Mixed
[[@search:results|query=topic,max_results=10]]
Built-in Actions
| Action | Purpose |
|---|---|
@fetch | Fetch a URL as markdown or HTML |
@search | Web search via DuckDuckGo |
@evidence | Keyword search over local text files |
@timestamp | Current date and time |
@history | Load conversation turns from a file |
@markdownify | Convert HTML to markdown |
@set | Placeholder; currently always returns an empty string |
@noop | Does nothing; stands in for unknown actions |
@fetch
Fetch content from a URL.
[[@fetch:content|url="https://example.com"]]
[[@fetch:content|url=user_url]]
[[@fetch:page|url=user_url,max_chars=0]]
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
url | str | Yes | - | URL to fetch |
raw | bool | No | false | Return raw HTML instead of markdown |
timeout | int | No | 8 | Request timeout in seconds; the default comes from STRUCKDOWN_WEB_FETCH_TIMEOUT |
max_chars | int | No | 64000 | Maximum characters returned; 0 means no limit |
playwright | bool | No | false | Fetch with a headless browser. Without it, requests are made directly and fall back to Playwright on a 401 or 403 |
Playwright needs the playwright package (pip install playwright, or struckdown’s dev extra) and playwright install chromium.
Returns: Page content as text. By default the main content is extracted and converted to markdown.
Blocked in the hosted playground (sd serve). An empty or invalid URL raises an error.
@search
Web search using DuckDuckGo. By default each result page is fetched and its content included.
[[@search:results|query="python tutorials",max_results=3]]
[[@search:results|query=user_query]]
[[@search:results|query=user_query,embed=false]]
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
query | str | Yes | - | Search query |
max_results | int | No | 5 | Maximum number of results |
embed | bool | No | true | Include each page’s fetched content; false gives titles and snippets only |
raw | bool | No | false | Keep fetched pages as HTML instead of markdown |
timeout | int | No | 8 | Per-request timeout in seconds (from STRUCKDOWN_WEB_FETCH_TIMEOUT) |
max_tokens | int | No | 10000 | Maximum characters per fetched page; 0 means no limit |
playwright | bool | No | false | Fetch pages with a headless browser |
Returns: Search results as formatted markdown text, or No results found for: <query>.
Blocked in the hosted playground (sd serve).
@evidence
Keyword search (BM25) over .txt and .md files in an evidence/ directory. There is no vector store or embedding step.
[[@evidence|topic]]
[[@evidence:docs|query=extracted_topic,n=3]]
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
query | str or list of str | Yes | - | Search query, or several; results are merged |
n | int | No | 3 | Number of passages returned |
Where it looks:
- If the context has
evidence_folder(a path or list of paths), only those. - Otherwise
evidence/next to the template file, andevidence/in the current directory.
Files are split into passages of about 500 characters. In the hosted playground, uploaded evidence is searched instead.
Returns: The top n passages, each headed by its file name, separated by ---. Returns an empty string if no folder or no match is found, or if the search fails.
sd chat -p prompt.sd -c evidence_folder=./my_docs
See RAG Tutorial.
@timestamp
The current local date and time.
[[@timestamp]]
[[@timestamp:today|format="%Y-%m-%d"]]
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
format | str | No | ISO 8601 | strftime format string |
@history
Loads conversation turns from a text file and adds them to the conversation as separate messages. Each non-empty line is one turn; roles alternate.
[[@history]]
[[@history|n=5]]
[[@history|role="assistant"]]
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filename | str | No | context["_history_file"] | Path to the file, relative to the current directory |
first | str | No | "assistant_first" | Who speaks first: "assistant_first" or "user_first" |
n | int | No | all | Keep only the last n turns (after role filtering) |
role | str | No | all | Keep only "user", "assistant" or "system" turns |
sd chat --history FILE sets _history_file. In sd chat -i, the live conversation is used instead of the file. Unknown parameters are logged and ignored. With no file given, the action raises an error. Registered with default_save=False, so a bare [[@history]] is not stored in the context.
@markdownify
Converts HTML to markdown.
[[@markdownify:md|html=raw_html]]
[[@markdownify:md|html=raw_html,extract_content=false]]
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
html | str | Yes | - | HTML to convert |
extract_content | bool | No | true | Extract the main readable content first; false converts the whole page |
@set
Intended for setting a variable without an LLM call. The current implementation ignores its parameters and always returns an empty string, so [[@set:greeting|value="Hello"]] sets greeting to "", not "Hello".
@noop
Returns an empty string and saves nothing. When a template uses an action name that is not registered, the parser warns (Unknown action '@name') and substitutes @noop.
Registering Custom Actions
from struckdown import Actions
@Actions.register('myaction')
def my_action(context, param1: str, param2: int = 10):
"""Action description"""
return f"Result: {param1} x {param2}"
Sync functions run in a thread; async def functions are awaited.
Registration Options
@Actions.register(
'myaction',
on_error='propagate', # 'propagate', 'return_empty' or 'return_default'
default='fallback value', # returned on error when on_error='return_default'
default_save=True, # store the result of a bare [[@myaction]]
return_type=MyModel, # Pydantic model used when reloading stored results
allow_remote_use=True, # allow in the hosted playground
role='user', # message role of the output in the conversation
)
def my_action(context, ...):
...
| Option | Default | Meaning |
|---|---|---|
on_error | "propagate" | Error handling; see below |
default | "" | Value returned on error when on_error="return_default" |
default_save | True | Whether [[@action]] (no variable name) stores its result under the action’s name. [[@action:var]] always stores it |
return_type | None | Pydantic model for this action’s output; see Pydantic Models |
allow_remote_use | True | False blocks the action in the hosted playground (sd serve); @fetch and @search use this |
role | "user" | Role of the message carrying the output: "user", "assistant" or "system". Ignored when the action returns a MessageList |
Actions can also be loaded from files: an actions/ directory next to the template or in the current directory is discovered automatically, and sd chat --tools / sd batch --tools load a file or directory explicitly.
Error Handling
| Mode | Behaviour |
|---|---|
propagate | Raise exception (default) |
return_empty | Return empty string on error |
return_default | Return default value on error |
Context Object
The first parameter, context, is a dict containing:
- All template variables
- Values of slots and actions that ran earlier in the template
- Internal keys set by struckdown or the CLI, such as
_template_pathand_history_file
@Actions.register('summarise')
def summarise(context):
name = context.get('name', 'unknown')
items = context.get('items', [])
return f"{name} has {len(items)} items"
Type Coercion
Before the call, parameters are validated against the function’s type hints with a Pydantic model in its default (lax) mode:
@Actions.register('multiply')
def multiply(context, value: int, factor: float = 2.0):
return str(value * factor)
# String "10" converted to int, "1.5" to float
[[@multiply:result|value="10",factor="1.5"]]
Template values arrive as strings, except unquoted numbers, which arrive as int or float, and variable references, which arrive as whatever the context holds. What lax mode does with them:
| Type | Coercion |
|---|---|
str | Strings unchanged. Unannotated parameters are treated as str. An unquoted number (n=3) arrives as a number, and lax mode does not turn a number into a string, so it fails |
int | "10" becomes 10; "1.5" fails |
float | "1.5" becomes 1.5 |
bool | "true", "yes", "1", "on" become True; "false", "no", "0", "off" become False |
List[T], Dict[str, T] | A JSON string is not parsed and fails validation; the value must already be a list or dict, for example a context variable holding one |
If any parameter fails validation, coercion is abandoned for the whole call: every parameter is passed to the function exactly as rendered, uncoerced, and only a debug-level log records the failure. A function that relies on coercion should check its inputs.
Return Values
The returned value is stored unchanged as the slot’s output and in the context. What goes into the conversation is str(value), sent as one message with the action’s role:
# String return
@Actions.register('greet')
def greet(context, name: str):
return f"Hello, {name}!"
# Dict return: stored as a dict; the prompt sees its Python repr
@Actions.register('get_user')
def get_user(context, id: int):
return {"name": "Alice", "id": id}
Return json.dumps(...) if the model should see JSON rather than a Python repr.
An action that returns a MessageList (as @history does) adds each message separately, with its own role.
Pydantic Models
An action can return a Pydantic model instance directly; it is stored as the object and the conversation gets str() of it.
return_type on register does not convert what the function returns. A returned JSON string stays a string. return_type is used only when saved results are loaded back from JSON: if a stored output for that action is a dict, it is rebuilt as an instance of return_type (and left as a dict, with a warning, if that fails).
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
@Actions.register('get_user', return_type=User)
def get_user(context, id: int):
return User(name="Alice", email="alice@example.com")
[[@get_user:user|id=123]]
Actions vs Slots
| Feature | Action [[@...]] | Slot [[...]] |
|---|---|---|
| Execution | Python function | LLM call |
| Cost | No API tokens (though @search/@fetch make web requests) | API tokens |
| Determinism | Depends on the function | Non-deterministic |
| Use case | Data retrieval, transforms | Generation, reasoning |