How to think about and run a Summoner client
A Summoner client is a TCP program that connects to a Summoner server, receives messages, and sends messages. It has no standalone purpose without a server; the server is the rendezvous point for communication.
However, a client focuses on messaging and orchestration primitives; it does not include the security and policy layers needed to operate safely on an open server with untrusted peers. To address this, we provide agent classes. An agent is a client with additional behavior:
SummonerClientis the base class.- An agent class subclasses
SummonerClientand adds features such as identity records, envelope handling, policy hooks, handshake logic, and orchestration helpers. You are free to design your own agent class for your project.
For the raw core surface, see SummonerClient.run(...), SummonerClient.receive(...), SummonerClient.send(...), and SummonerClient.hook(...).
Note
A reference agent class is available through Aurora (implemented in extension-agentclass). It provides an opinionated baseline for orchestration: identity records, message signing and verification, handshake scaffolding, policy-driven validation, and route/flow helpers. Use it as a ready-made starting point if you prefer not to build these pieces yourself. For the broader add-on space, see the Agent Extensions reference, and for the concrete class interface see SummonerAgent.
You need a server to connect to. This can be your local server (127.0.0.1), a teammate's machine, or a hosted server.
Clients do not require special parameters to connect to either the Python or Rust server: the wire protocol is the same. The server's implementation is transparent to the client.
First, start a local server. See Getting Started with Summoner Servers, and then run:
python first_server.pySecond, create a minimal client in a python file, say first_client.py, as shown below. The host and port link to the local server that you started earlier:
from summoner.client import SummonerClient
client = SummonerClient()
client.run(host="127.0.0.1", port=8888)After this, run the script:
python first_client.pyYou should see the client connect to the local server
[DEBUG] Config file not found: `None`
2025-09-13 14:43:33.525 - <client:no-name> - INFO - Connected to server @(host=127.0.0.1, port=8888)
(Click to expand) If you stop the server (Ctrl+C), the client's reconnection logic kicks in. The default policy retries the "primary" address, then falls back to a "default" address (if configured), then exits.
2025-09-13 15:20:48.967 - <client:no-name> - ERROR - [ServerDisconnected: EOF during read] (Primary) retry 1 of 3; sleeping 3s
2025-09-13 15:20:51.969 - <client:no-name> - ERROR - [ConnectionRefusedError: [Errno 61] Connect call failed ('127.0.0.1', 8888)] (Primary) retry 2 of 3; sleeping 3s
2025-09-13 15:20:54.972 - <client:no-name> - ERROR - [ConnectionRefusedError: [Errno 61] Connect call failed ('127.0.0.1', 8888)] (Primary) retry 3 of 3; sleeping 3s
2025-09-13 15:20:57.973 - <client:no-name> - ERROR - Primary retry limit reached (3)
2025-09-13 15:20:57.974 - <client:no-name> - WARNING - Falling back to default server at None:None
2025-09-13 15:20:57.974 - <client:no-name> - ERROR - [ConnectionRefusedError: [Errno 61] Connect call failed ('127.0.0.1', 8888)] (Default) retry 1 of 2; sleeping 3s
2025-09-13 15:21:00.977 - <client:no-name> - ERROR - [ConnectionRefusedError: [Errno 61] Connect call failed ('127.0.0.1', 8888)] (Default) retry 2 of 2; sleeping 3s
2025-09-13 15:21:03.978 - <client:no-name> - ERROR - Default retry limit reached (2)
2025-09-13 15:21:03.979 - <client:no-name> - CRITICAL - Cannot connect to fallback None:None after 2 attempts; exiting
Tip
You control retry delays and limits via the client config (next section). For the exact knobs, see the reconnection reference, especially retry_delay_seconds, primary_retry_limit, and the fallback default_host / default_port.
Configuration can override address selection and tune reconnection behavior. The full config surface is documented in the client configuration reference.
To see this, create a configuration file client_config.json containing the following JSON structure:
{
"host": null,
"port": null,
"hyper_parameters": {
"reconnection": {
"retry_delay_seconds": 1,
"primary_retry_limit": 1,
"default_host": "localhost",
"default_port": 8888,
"default_retry_limit": 1
}
}
}The run the client with the config:
from summoner.client import SummonerClient
client = SummonerClient()
client.run(host="127.0.0.1", port=8888, config_path="client_config.json")(Click to expand) Stopping the server now leads to faster retries and exit, as dictated by the config.
2025-09-13 15:21:24.339 - <client:no-name> - INFO - Connected to server @(host=127.0.0.1, port=8888)
2025-09-13 15:21:28.320 - <client:no-name> - INFO - Client about to disconnect...
2025-09-13 15:21:28.321 - <client:no-name> - ERROR - [ServerDisconnected: EOF during read] (Primary) retry 1 of 1; sleeping 1s
2025-09-13 15:21:29.322 - <client:no-name> - ERROR - Primary retry limit reached (1)
2025-09-13 15:21:29.323 - <client:no-name> - WARNING - Falling back to default server at localhost:8888
2025-09-13 15:21:29.332 - <client:no-name> - ERROR - [OSError: Multiple exceptions: [Errno 61] Connect call failed ('::1', 8888, 0, 0), [Errno 61] Connect call failed ('127.0.0.1', 8888)] (Default) retry 1 of 1; sleeping 1s
2025-09-13 15:21:30.333 - <client:no-name> - ERROR - Default retry limit reached (1)
2025-09-13 15:21:30.333 - <client:no-name> - CRITICAL - Cannot connect to fallback localhost:8888 after 1 attempts; exiting
If both your Python code and the config file specify a host/port, the config file takes precedence. This allows deployments to be steered without code changes.
For example, you can force the client to use testnet.summoner.org even if code passes a different address:
{
"host": "testnet.summoner.org",
"port": 8888,
"hyper_parameters": {
"reconnection": {
"retry_delay_seconds": 3,
"primary_retry_limit": 5,
"default_host": "localhost",
"default_port": 8888,
"default_retry_limit": 3
}
}
}If you run the same Python snippet as above, you should see:
[DEBUG] Loaded config from: client_config.json
2025-09-13 15:25:46.398 - first_client - INFO - Connected to server @(host=testnet.summoner.org, port=8888)
In Summoner, an agent is by definition a subclass of SummonerClient. You can define your own agent class by starting with a minimal subclass of SummonerClient and then add behavior.
from summoner.client import SummonerClient
class MyAgent(SummonerClient):
pass
agent = MyAgent()
agent.run(host="127.0.0.1", port=8888)With an empty subclass of SummonerClient as above, the output is the same as the base client; the difference is you now have a place to attach handlers and hooks.
Note
SummonerAgent is available as part of Aurora via extension-agentclass. You can continue to build your own agent classes; the Aurora reference class is simply a faster starting point for common orchestration, identity, and policy needs. If you want the wider module map first, start with the Agent Extensions reference.
Agents typically do two things:
- receive messages from the server and react
- send messages to the server (periodically or in reaction to input)
Both are declared with decorators on the instance. The precise decorator contracts live in the references for @receive, @send, and @hook.
Important
Handlers must be async. The client validates signatures at registration.
The simplest receiver behavior prints messages received from the server:
from typing import Any
from summoner.client import SummonerClient
agent = SummonerClient()
@agent.receive(route="")
async def recv_handler(msg: Any) -> None:
print(f"received: {msg!r}") # runs concurrently with senders
agent.run(host="127.0.0.1", port=8888)route=""acts as a catch-all for this quick start; its string value is inconsequential.- Receive handlers run concurrently with senders.
- The function receives whatever other peers send (string or JSON-like dict).
For options such as priority and flow-aware route matching, see the SummonerClient.receive(...) reference.
The clearest way to express a steady heartbeat is to register a timed sender with every=...:
from summoner.client import SummonerClient
agent = SummonerClient()
@agent.send(route="", every=1.0)
async def heartbeat():
print("Sending 'ping'")
return "ping"
agent.run(host="127.0.0.1", port=8888)This keeps sending because the SDK schedules the sender every second. Older patterns that put await asyncio.sleep(...) inside the sender body still work, but every=... is usually clearer when all you want is a recurring cadence. The broader send surface, including multi=True, use_data=True, when_data, run_while, and data_mode="snapshot", is documented in the SummonerClient.send(...) reference.
To send only once, return a value the first time and None afterward:
from summoner.client import SummonerClient
agent = SummonerClient()
_sent = False
@agent.send(route="")
async def hello_once():
global _sent
if _sent:
return None # no message this cycle
_sent = True
print("Sending 'Hello'")
return "Hello"
agent.run(host="127.0.0.1", port=8888)Returning None means no message this cycle. Later, when you use flows, a reactive sender can also consume Event.data directly with use_data=True instead of coordinating through shared flags or helper queues by hand. The full reactive sender options are in the @send reference.
Composition is central to how you scale an agent without rewriting it. The practical unit of reuse is a capability: a small set of @receive/@send handlers tied to a set of routes, or sometimes a set of route types classified through Node logic. You can copy these blocks between agents to add or remove skills.
Mental model
-
Treat each route as a lane dedicated to one capability (e.g.,
"echo","heartbeat","orders"). -
A capability usually has:
- one or more
@receivehandlers for that lane - zero or more
@sendhandlers that produce messages on that lane
- one or more
-
An agent with multiple lanes is effectively a bundle of sub-agents that share the same process and connection.
Small two-capability example
from typing import Any
from summoner.client import SummonerClient
agent = SummonerClient()
# Capability 1: echo
@agent.receive(route="echo")
async def echo_rx(msg: Any) -> None:
print(f"[echo] {msg!r}")
@agent.send(route="echo", every=2.0)
async def echo_tx():
return {"kind": "echo", "text": "hello"}
# Capability 2: heartbeat
@agent.send(route="heartbeat", every=5.0)
async def hb_tx():
return "hb"
agent.run(host="127.0.0.1", port=8888)This single agent exposes two clear capabilities on distinct routes. In a larger project, you can keep each capability in its own file and register them onto the same agent instance. If you later need to assemble capabilities authored in separate agents, see ClientMerger in the SDK reference; it replays handlers from multiple agents into one process.
Tip
Choose short, stable route names. Routes are your namespace for composition. Keeping them consistent makes it easy to move capabilities between agents.
« Previous: Getting Started with Summoner Servers | Next: Orchestrating Agent Behavior Using Flows »
