Skip to content

Actions (C++)

An action is a name, a declared input shape, and a coroutine. The builder chains from the host builder and handler is the terminal step, which hands the host builder back so the next action follows.

The canonical todo example registers addTodo like this:

builder.action("addTodo")
.description("Add one todo")
.input(tesseron::schema::object({
tesseron::schema::required("text", tesseron::schema::string().min_length(1)),
tesseron::schema::optional("tag", tesseron::schema::string()),
}))
.output_schema(todo_output_schema())
.handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {
const auto tag = optional_string(input, "tag");
if (!tag.ok()) co_return tag.error();
Todo todo = state->create(input.at("text").get<std::string>(), tag.value());
state->publish();
co_return todo_payload(todo);
});

description is what the agent reads when it decides whether to call this at all, so write it for a reader who has never seen your application. timeout overrides the gateway's 60-second default for this action only.

A handler is a C++20 coroutine with this shape:

boost::asio::awaitable<Result<Json>> addTodo(Json input, ActionContext context);

The shipped examples use the same return type for every action handler. The coroutine runs on the host's I/O thread, so a co_await on a sample or an elicitation yields instead of blocking the read loop. A handler that blocks that thread stalls the whole session. Push real work onto your own executor and co_await the result.

Schema answers both questions with one object: it emits the JSON Schema that goes in the manifest, and it validates the input at dispatch. The contract the agent reads is the contract the handler is protected by, so the two cannot drift apart.

The canonical importTodos action uses the builder for its object, array, and length constraints:

builder.action("importTodos")
.description("Import several todos")
.input(tesseron::schema::object({
tesseron::schema::required("items", tesseron::schema::array(tesseron::schema::string()).min_items(1).max_items(50)),
tesseron::schema::optional("tag", tesseron::schema::string()),
}))
.output_schema({
{"type", "object"},
{"properties", {{"added", {{"type", "integer"}}}, {"ids", {{"type", "array"}, {"items", {{"type", "string"}}}}}}},
{"required", {"added", "ids"}},
})
.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {
const auto tag = optional_string(input, "tag");
if (!tag.ok()) co_return tag.error();
const Json& items = input.at("items");
Json identifiers = Json::array();
for (std::size_t index = 0; index < items.size(); ++index) {
Todo todo = state->create(items[index].get<std::string>(), tag.value());
identifiers.push_back(todo.identifier);
context.progress(ProgressUpdate()
.message(std::to_string(index + 1) + "/" + std::to_string(items.size()) + " imported")
.percent(static_cast<int>((index + 1) * 100 / items.size())));
}
state->publish();
co_return Json{{"added", identifiers.size()}, {"ids", identifiers}};
});

min_length and max_length count UTF-8 code points, not bytes, so a schema written for a human-visible field means what it looks like it means.

Input that fails the schema never reaches the handler. The agent gets -32004 with every issue at once, each carrying the path into the input:

{
"code": -32004,
"message": "Invalid input",
"data": [
{ "message": "required property is missing", "path": ["sku"] },
{ "message": "expected type \"integer\", got string", "path": ["quantity"] }
]
}

For a shape the builder cannot express, pass the JSON Schema document and the check together:

builder.action("query")
.input_schema(load_schema_document(), [](const Json& input) {
return your_validator.check(input);
})
.handler(run_query);

The validator is required. A schema nothing enforces is a promise to the agent that the handler does not keep, and the failure surfaces inside the handler instead of as a -32004 the agent can act on.

Host::listen() returns a Host whose action registry can change from any thread. Build an Action value with the standalone ActionDefinition chain, then register it after listening; a same-name registration replaces the previous action, while HostBuilder still rejects duplicate names when listen() runs.

auto action = tesseron::ActionDefinition("refresh")
.description("Refresh cached data")
.input(tesseron::schema::object({}))
.handler(refresh);
host.register_action(action);

host.remove_action(name) returns bool, with false when no action has that name, and a removal only notifies when it changes the registry. A live session receives actions/list_changed with the full current list after each registry change; without a live session, the next hello or resume announces the current registry.

ActionError has three factory methods. The difference between them is what reaches the agent.

A missing todo id in the shipped example returns -32005 HandlerError with structured data:

co_return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, "Todo not found",
Json{{"kind", "not_found"}});

Use ActionError::handler(message) for the same -32005 code without custom data. Use ActionError::protocol(code, message, data) when the agent needs a specific code and structured detail. Use ActionError::internal(source) when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives -32603 Internal error. A handler that throws is treated the same way.

The gateway sends actions/cancel, and the host answers -32001 immediately: it does not wait for the handler to notice. The stop token gives the handler a chance to stop doing the work.

The canonical import handler checks its progress loop and reports each item:

.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {
const auto tag = optional_string(input, "tag");
if (!tag.ok()) co_return tag.error();
const Json& items = input.at("items");
Json identifiers = Json::array();
for (std::size_t index = 0; index < items.size(); ++index) {
Todo todo = state->create(items[index].get<std::string>(), tag.value());
identifiers.push_back(todo.identifier);
context.progress(ProgressUpdate()
.message(std::to_string(index + 1) + "/" + std::to_string(items.size()) + " imported")
.percent(static_cast<int>((index + 1) * 100 / items.size())));
}
state->publish();
co_return Json{{"added", identifiers.size()}, {"ids", identifiers}};
});

stop_token(), cancelled(), and wait_for_cancellation() expose the same cancellation signal to the handler. Settlement is first-wins between the handler returning, cancellation arriving, and the timeout firing, so one request cannot receive two answers.