tapio
A Pekko-inspired actor toolkit for Python. Typed, asyncio-native actors with supervision, and Pydantic models throughout.
Status: pre-alpha. The runtime core runs: actor systems, spawning, typed
tell, bounded mailboxes, dead letters, a deadline-based shutdown, supervision with backoff, death watch,ask, timers, stash, message adapters,run_blockingand a round-robin pool router. Two systems can also talk over a TCP link, with a handshake, optional TLS and messages that carry refs across, plus watching and asking over the link, a failure detector, quarantine, an explicit reconnect, and starting an actor on another node. Nodes can form a cluster and agree on who is in it. Deciding what to do about a member that has stopped answering is still to come.
Why this exists.
tapiois a testbed for AI agentic development. The point of the project is to find out what coding agents can carry on their own over a real codebase: a non-trivial design, invariants that span modules, and a gate that has to stay green. Nearly all of the code, tests and documentation here is written by agents under human review.The library itself is meant to work, and the design decisions are argued rather than generated. Treat it as an experiment that happens to be working software, not as something to depend on in production yet.
Named for the Finnish god of the forest, because supervision hierarchies are trees. It keeps the mythological lineage of Akka (Sámi) and Apache Pekko (Finnish) without borrowing anyone's trademark. See the note at the bottom.
What it is
tapio gives you the concurrency structure of Apache Pekko
(itself the ASF fork of Akka) inside a single Python process:
- Actors: isolated state, one mailbox, no locks
- Supervision: restart with backoff, escalate, stop. Failure policy is a
first-class thing rather than scattered
try/except - Death watch: learn when a child dies, without polling
- Ask, timers, stash, routers: the patterns you would otherwise hand-roll
- Remoting: two systems on a TCP link, sending each other typed messages
- Membership: nodes gossip a view of the cluster that they all converge on, with a leader that is computed rather than elected
It is a library, not infrastructure. Pip-install it into the service you already have. Nodes find each other from a seed list you deploy with them, so there is no broker, no coordinator and no separate cluster process to run.
What it is not
This is inspired by Pekko, not a port, and shares no code with it. Deliberately out of scope, permanently:
- Sharding and distributed data. Placing an entity on one node, moving it when that node goes away, or replicating state so that two nodes can write it, is a much larger problem than agreeing on who is in the cluster. If you need either, use Ray or put a broker between your nodes.
- Competing with the JVM on throughput. See below.
Clustering as a whole used to be on that list, and the reason given was that reimplementing gossip and split-brain resolution in Python is a multi-year project with a high bug-severity floor. That reason was right about the second half. Membership is here because the merge two nodes run to agree is a pure function with three laws behind it, so it can be tested as one, and it is. Deciding what to do about a member that has stopped answering is the part with the high bug-severity floor, and it is not here. A cluster today agrees on who joined and who left gracefully, and it stops making decisions when a member goes quiet rather than guessing which side of a partition should survive.
Remoting does give you location transparency in the narrow sense: an actor holding a ref just sends, and does not need to know which node the target is on. What it does not change is the failure model. A network is in the middle, delivery over a link is at-most-once, and a message that crossed one is equal to what was sent rather than the same object.
Where it fits
The target is I/O-bound orchestration: many independent, long-lived, stateful things, each mostly waiting on something external, each able to fail on its own.
Good fits:
- A session actor per user, holding conversation state and calling an LLM API
- Saga orchestration: payment, then inventory, then shipping, compensating on failure
- One actor per websocket, with behavior-switching as the protocol state machine
- Rate limiting and circuit breaking, where the mailbox is the mutex
Bad fits, use something else:
- High-volume per-record stream processing → Bytewax, Quix Streams
- Anything CPU-heavy inside a handler. One blocking call stalls every actor sharing the event loop.
Actors are not microservices. A microservice is a unit of deployment. An actor
is a unit of concurrency. You will have tens of thousands of actors inside one
service, each an asyncio.Task, so the ceiling is memory rather than a
cluster: an idle actor costs about 15 KB, so a hundred thousand of them fit in
1.6 GB. tapio structures the inside of that service. It competes with
asyncio.Queue + TaskGroup + a dict + hand-rolled retries, not with your
API framework.
A first actor
import asyncio
from tapio import ActorSystem, Behavior, Behaviors, Message
from tapio.actor import ActorContext, ActorRef
class Greeted(Message):
whom: str
class Greet(Message):
whom: str
reply_to: ActorRef[Greeted]
async def on_greet(ctx: ActorContext[Greet], message: Greet) -> Behavior[Greet]:
ctx.log.info("hello, %s!", message.whom)
message.reply_to.tell(Greeted(whom=message.whom))
return Behaviors.same()
async def on_greeted(message: Greeted) -> Behavior[Greeted]:
print(f"{message.whom} has been greeted")
return Behaviors.same()
async def main() -> None:
async with ActorSystem("hello") as system:
listener = system.spawn(Behaviors.receive_message(on_greeted), name="listener")
greeter = system.spawn(Behaviors.receive(on_greet), name="greeter")
greeter.tell(Greet(whom="world", reply_to=listener))
await asyncio.sleep(0.1)
asyncio.run(main())
Runnable versions of this and every other example live in examples/:
uv run python -m tapio_examples.hello_world
Design notes
Sending never blocks. ref.tell(msg) is synchronous and fire-and-forget,
so it works from sync callbacks and signal handlers too. Backpressure belongs
to the mailbox rather than the send call. Bounded mailboxes take an overflow
strategy (fail, drop_new, drop_oldest), and all three publish what they
drop as a dead letter. await ref.offer(msg) waits for capacity when you want
to be throttled.
Every message is a validated Pydantic model. Messages subclass
tapio.Message, which is frozen and re-validated on delivery rather than only
at construction. What that costs depends on the model: about 30% more per
message for a one-field message, and about 3x for a ten-field one with nested
models. In an actor that spends 50 ms on an HTTP call it is invisible. In a
tight per-record loop it dominates, which is why that workload is out of scope
above. The check sits behind a single validate_on_tell setting. The
numbers are below.
Undeliverable messages go to dead letters. An ActorRef stays valid after
its actor dies, so tell never raises for a dead target. The message is
published as a DeadLetter you can subscribe to and assert on. To know when
something died, watch it with ctx.watch rather than asking whether it is
alive. A liveness answer is out of date as soon as you have it.
Requires Python 3.11+ (for asyncio.timeout() and typing.Self).
Numbers
Measured with make bench and make bench-scale, which anybody can run. Both
print what they ran on, and this is what they printed:
Intel(R) Core(TM) i7-5600U CPU @ 2.60GHz, 4 cores, 8 GB RAM
Linux 7.1.5-arch1-2
CPython 3.11.11, pydantic 2.13.4
measured 2026-08-07
The figures below are the best of twenty rounds, on a laptop that was doing other things at the time. Take the ratios seriously and the absolute numbers as an order of magnitude: that is a 2015 mobile part, and a current server core is considerably faster.
Messages per second, one sender to one actor, ten thousand at a time:
| Message | validate_on_tell=True |
validate_on_tell=False |
What validation costs |
|---|---|---|---|
one int field |
183,000/s | 241,000/s | 1.3x |
| ten fields, two of them nested models | 55,000/s | 166,000/s | 3.0x |
That is the design bet of this library, priced. Revalidation on delivery is not free and it is not 10x either: it is a property of your message, and the setting is there for the case where you have measured it and it matters.
Everything else, locally:
| Starting an actor | 105 us |
ask round trip |
121 us |
Across a link, two systems over loopback, so the network itself is nearly free and what is left is the codec and the socket:
| Local | Remote | Ratio | |
|---|---|---|---|
| Messages per second | 183,000/s | 9,200/s | 20x slower |
ask round trip |
121 us | 1.2 ms | 10x slower |
JSON on the wire is the cost there, and it is the reason the codec sits behind one module with the frame format versioned: a binary codec is an additive change rather than a rewrite.
Resident actors, idle but able to answer, each measured in its own process:
| Actors | RSS | Per actor | ask p50 |
ask p99 |
|---|---|---|---|---|
| 1,000 | 53 MB | 14.9 KB | 89 us | 216 us |
| 10,000 | 190 MB | 14.8 KB | 93 us | 1,260 us |
| 100,000 | 1,557 MB | 14.8 KB | 104 us | 597 us |
Memory per actor is flat, and the median round trip to one actor among a hundred thousand is the same as to one among a thousand: a mailbox nobody is sending to costs nothing to have. The p99 column is the honest part of this table. It does not grow with the number of actors, it wanders, because what puts a tail on a round trip here is the garbage collector walking a large heap rather than anything in the runtime.
Development
Managed with uv. The Makefile is the entry
point and the single source of truth for what CI runs.
make # list every target
make install # create the venv, install deps
make check # pre-push gate: lint + types + tests
make ci # exactly what GitHub Actions runs
Trademark note
Apache Pekko and Apache Kafka are trademarks of the Apache Software Foundation. This project is not affiliated with, endorsed by, or derived from the Apache Pekko codebase. It is an independent implementation inspired by its design, and references to Pekko above are descriptive only.
License
Apache-2.0. Copyright 2026 Carmelo Polito.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tapio_py-0.6.0.tar.gz.
File metadata
- Download URL: tapio_py-0.6.0.tar.gz
- Upload date:
- Size: 425.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7a50991af82c0c59bdd3e0677d438262c9a0874b1aee1396fa0db051c25951e5
|
|
| MD5 |
6141bd282a905e178541db89422ab86d
|
|
| BLAKE2b-256 |
b1bf14b9ad0d7b70b1e6a8096d83442febf1eb02204ff820fe5aea980dcb5117
|
File details
Details for the file tapio_py-0.6.0-py3-none-any.whl.
File metadata
- Download URL: tapio_py-0.6.0-py3-none-any.whl
- Upload date:
- Size: 203.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e9d217e2c6d3132cb60b0c6a986c70988f553d2475df0302b7e3329cce26138
|
|
| MD5 |
21ebcfcd41e13ec9640709fe826a3a72
|
|
| BLAKE2b-256 |
8e30a4f6b4e9d94f8a73651806da0b89f44bc4fd2fbd1e142cbba636788e5372
|