Actions (Python)
An action is a named, typed, handler-backed operation the agent can invoke. The gateway projects each one into an MCP tool.
The decorator
Section titled “The decorator”from pydantic import BaseModel, Fieldfrom tesseron import ActionContext, JsonObject, TesseronApp
class AddTodoInput(BaseModel): text: str = Field(min_length=1) tag: str | None = None
def create_app() -> TesseronApp: app = TesseronApp(id="python_todo", name="Python Todo")
@app.action("addTodo", description="Add one todo") async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject: del context todo = store.create(input_data.text, input_data.tag) await publish_todos() return todo_payload(todo)
return appThe store, publish_todos, and todo_payload names above come directly from the canonical examples/todo/app.py host.
Every handler is async def and takes (input_data, context). A handler that is not a coroutine function, or that does not take two parameters, raises HostError at registration. Registering one name twice raises DuplicateNameError, because the manifest has to stay unambiguous for the gateway to project it.
Input comes from the annotation
Section titled “Input comes from the annotation”If the first parameter is annotated with a Pydantic BaseModel, that model is the input contract. Two things follow from it:
- The manifest publishes
model_json_schema(mode="validation"), unchanged. Validation mode is the right one here: the agent is producing input, not reading output, so aliases and defaults have to be described the way the model will accept them. - Dispatch runs
model_validatebefore the handler body. Input that does not fit is refused with-32004 InputValidation, and the handler never runs.
The refusal carries every problem Pydantic found, not just the first:
{ "code": -32004, "message": "Invalid input", "data": [ { "message": "String should have at least 1 character", "path": ["text"] }, { "message": "Input should be a valid string", "path": ["tag"] } ]}Raw JSON input
Section titled “Raw JSON input”Annotate the first parameter with anything else and the handler takes the invocation input as raw JSON. Then input_schema is what the manifest publishes and validate is what enforces it:
from tesseron import ActionContext, JsonValue, ValidationIssue
def positive_amount(raw_input: JsonValue) -> list[ValidationIssue]: if isinstance(raw_input, dict) and isinstance(raw_input.get("amount"), int | float): return [] return [ValidationIssue(message="amount must be a number", path=["amount"])]
@app.action( "charge", description="Charge the saved card", input_schema={"type": "object", "properties": {"amount": {"type": "number"}}}, validate=positive_amount,)async def charge(raw_input: JsonValue, context: ActionContext) -> JsonValue: return {"charged": True}A non-empty issue list becomes the same -32004 failure, with the same data shape. Leave validate off and nothing is checked: the schema is then documentation for the agent and nothing more.
The other options
Section titled “The other options”| Argument | What it does |
|---|---|
description | Published in the manifest. The gateway uses it as the MCP tool description, so write it for the agent. |
input_schema | Overrides the schema derived from the model, or supplies one for a raw handler. |
output_schema | Published when set. Nothing validates output against it; it tells the agent what to expect. |
timeout_ms | Per-action deadline. Past it the invocation answers -32002 Timeout and the handler task is cancelled. The default is 60 seconds. |
validate | Extra input check for a raw handler. Ignored when the input type comes from a model. |
Registering after listen
Section titled “Registering after listen”await app.listen() returns a TesseronHost. Its action decorator has the same arguments as app.action and can register an action after the host starts:
host = await app.listen()
@host.action("refresh", description="Refresh cached data")async def refresh(raw_input: JsonObject, context: ActionContext) -> JsonObject: del raw_input, context return {"ok": True}Host registration upserts by name. Registering an existing name replaces its descriptor and handler while keeping its position in the manifest. host.remove_action(name) returns True when an action was removed and False when the name was unknown. The app-level decorator still raises DuplicateNameError for a duplicate.
After the gateway welcomes the session, each registry change sends actions/list_changed with { "actions": [full manifest] }. Changes before welcome, or with no gateway connected, are silent; the next hello or resume carries the updated manifest. Each change sends one notification, with no coalescing.
These calls are synchronous and must run on the event loop thread, like the rest of the SDK.
What a handler may return
Section titled “What a handler may return”Output is converted to JSON before it leaves. Pydantic models go through model_dump(mode="json"), enums through their value, mappings and sequences recursively, and None, bool, int, float, str as they are.
Anything else has no defined wire shape, so it fails as an internal error rather than reaching the agent as the string of a repr. Return a model or a dict.
Failing on purpose
Section titled “Failing on purpose”Raise ActionError when the handler cannot produce its output:
from pydantic import BaseModelfrom tesseron import ActionContext, ActionError, JsonObject
class TodoIdentifierInput(BaseModel): id: str
@app.action("deleteTodo", description="Delete one todo")async def delete_todo(input_data: TodoIdentifierInput, context: ActionContext) -> JsonObject: del context original_length = len(store.todos) store.todos[:] = [todo for todo in store.todos if todo.id != input_data.id] if len(store.todos) == original_length: raise ActionError.handler("Todo not found", {"kind": "not_found"}) await publish_todos() return {"id": input_data.id, "removed": True}This is the canonical deleteTodo shape from examples/todo/app.py.
ActionError.handler sends its message and data to the agent as -32005. ActionError.protocol(code, message, data) does the same under a code you pick. ActionError.internal(cause) keeps the cause on your side and answers with a bare -32603 Internal error, which is what an unhandled exception in a handler is turned into too. See errors.