Samtale
A modern, minimal messaging framework for agents.
Samtale lets independent Python components exchange typed requests and structured outcomes over HTTP. An agent can be a sensor, controller, service, state machine, or LLM-backed application.
Samtale provides a uniform message envelope, Pydantic payload validation,
bounded handler concurrency, and three outcomes: ok, rejected, or error.
When an agent does not understand a request, it responds with the message types
and JSON schemas it accepts.
Samtale provides no broker, orchestration, memory, conversation history, workflow engine, or LLM dependency.
request → envelope validation → handler lookup → payload validation
→ handler execution → ok / rejected / error
Python 3.11 or later is required.
Installation
pip install samtale
A small agent
from typing import Literal
from pydantic import BaseModel
from samtale import Agent, Message
class WeatherRequest(BaseModel):
location: str
unit: Literal["c", "f"] = "c"
weather = Agent("weather")
@weather.on("weather_request", model=WeatherRequest)
async def get_weather(message: Message, request: WeatherRequest):
return {
"location": request.location,
"temperature": 18.4,
"unit": request.unit,
}
if __name__ == "__main__":
weather.run(port=8003)
Call it from another agent:
import asyncio
from samtale import Agent
async def main() -> None:
consumer = Agent("consumer")
try:
result = await consumer.ask(
"http://localhost:8003",
"weather_request",
location="London",
unit="c",
)
print(result)
finally:
await consumer.close()
if __name__ == "__main__":
asyncio.run(main())
Rejection is an outcome
An agent can understand a request and still decline it for a domain reason:
from pydantic import BaseModel
from samtale import Message, Agent
class SetTemperature(BaseModel):
value: float
weather = Agent("weather")
@weather.on("temperature.set", model=SetTemperature)
async def set_temperature(message: Message, request: SetTemperature):
if request.value > 21:
return weather.reject(
message,
"outside_supported_range",
{
"requested": request.value,
"maximum": 21,
},
)
return {"value": request.value}
send() returns that rejection normally, leaving the next decision to the
caller:
from samtale import Status, Agent
consumer = Agent("consumer")
async def set_temperature_with_fallback() -> None:
response = await consumer.send(
"http://localhost:8003",
"temperature.set",
value=24,
)
if (
response.status is Status.REJECTED
and response.reason == "outside_supported_range"
):
response = await consumer.send(
"http://localhost:8003",
"temperature.set",
value=response.payload["maximum"],
)
The framework standardises the exchange; the agents decide what happens next.
Self-describing rejections
If the consumer sends an unsupported message type, the weather agent returns a normal rejection with its accepted messages. The relevant response fields look like this:
{
"status": "rejected",
"reason": "unsupported_message",
"payload": {
"accepted_messages": [
{
"type": "weather_request",
"schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"enum": ["c", "f"], "default": "c"}
},
"required": ["location"]
}
}
]
}
}
Invalid payloads are also rejected and include the expected message schema alongside structured validation issues.
Sending messages
The three outbound methods share the same acknowledged HTTP exchange:
send()returns the complete responseMessage, including rejections.ask()expects success and returns only the response payload.emit()expects success and discards the response payload.
ask() and emit() raise RemoteRejection for ordinary domain rejections and
RemoteError for unexpected remote failures. emit() is a convenience method,
not guaranteed delivery or true fire-and-forget.
Handlers registered with @agent.on(...) must be defined with async def.
Synchronous handlers are rejected during registration so an invalid handler does
not fail only after receiving a request.
Timeouts
request_timeout controls outbound HTTP operations made by send(), ask(),
and emit(). If the remote agent does not complete the exchange within the
HTTP client's timeout limits, the caller receives SendTimeout.
handler_timeout independently limits one local handler invocation. When the
limit is exceeded, the invocation is cancelled and the agent returns an error
message with reason handler_timeout using HTTP 504:
# Calls other agents, with an outbound timeout
consumer = Agent("consumer", request_timeout=5.0)
# Cancels local handlers that run too long
weather = Agent("weather", handler_timeout=10.0)
These values are local limits. Samtale does not propagate a deadline through a chain of agents, and time already spent by an upstream agent is not subtracted from a downstream timeout. Applications that need an end-to-end deadline must carry and enforce one as part of their own message protocol.
Concurrency
Agents execute one handler at a time by default. Set max_concurrency when an
agent should continue processing while another handler awaits I/O:
agent = Agent("weather", max_concurrency=8)
At most eight handlers execute simultaneously; additional messages remain in
the inbox. Concurrent handlers can access the same local state across await
points, so applications should protect shared mutable state when necessary.
The inbox is unlimited by default. Set max_queue_size to bound the number of
requests waiting for a handler:
agent = Agent(
"weather",
max_concurrency=4,
max_queue_size=20,
)
Here, four handlers may run while twenty additional requests wait. Once the
inbox is full, new requests are not queued. They receive an immediate
rejected response with reason agent_busy and payload {"retryable": true}.
The response uses HTTP 200; send() returns it normally, while ask() and
emit() raise RemoteRejection. Samtale does not retry automatically.
Wire format
A request is a JSON object:
{
"id": "request-id",
"sender": "consumer",
"type": "weather_request",
"payload": {"location": "London", "unit": "c"}
}
The response retains the domain type and correlates itself with reply_to:
{
"id": "response-id",
"sender": "weather",
"type": "weather_request",
"payload": {"location": "London", "temperature": 18.4, "unit": "c"},
"reply_to": "request-id",
"status": "ok"
}
HTTP status describes the HTTP-level outcome. Message status describes the domain outcome. Rejections use HTTP 200; malformed input and runtime failures use the corresponding HTTP error status.
Run the included example
From a checkout:
uv sync --locked
uv run python examples/weather.py
In another terminal:
uv run python examples/weather_consumer.py
The consumer first demonstrates schema discovery with an unsupported request, then sends a valid typed request.
Tests
uv run python -m unittest discover -s tests -v
The GitHub Actions workflow runs the suite on Python 3.11, 3.12, and 3.13.
Release files for samtale 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| samtale-0.1.1.tar.gz | 34.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| samtale-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 51.1 kB
Release files / samtale-0.1.1.tar.gz
| Download URL | samtale-0.1.1.tar.gz |
|---|---|
| Size | 34.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
02615db9ffedfb6ae4279a3fbd33aebbd675f98453d371a56eb529fdf804635b
|
|
BLAKE2b-256 checksum How to use checksums |
941759d5c31c6082180024cf94f9a24f7cb2f95b3c68c1e5cc7895671f45b68d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / samtale-0.1.1-py3-none-any.whl
| Download URL | samtale-0.1.1-py3-none-any.whl |
|---|---|
| Size | 16.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f70d9020aab6e92a99db3de03bfd482524d97918d633444e76695fabf0f1937d
|
|
BLAKE2b-256 checksum How to use checksums |
c527180ecd7ac90cc8a301056aee2d36007c34e77b0555a038565fb8dc5c9d02
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|