Python SDK¶
The Python SDK is the package ankka, for Python 3.12 and later. A Python service is a process that the
ankka sidecar hosts: the sidecar owns the journal, sharding, projections, timers, HTTP and the agent loop,
and your classes decide what each command does. This page lists what each component kind is made of.
Services in other languages explains the model.
Installing¶
The SDK is on PyPI as ankka. Every ankka release publishes the SDK
at the same version, so pin the version of the platform you deploy to:
uv add "ankka==0.16.1" # the SDK
uv add "ankka[testkit]==0.16.1" # with the integration testkit's dependencies
pip install ankka==0.16.1 does the same for a project that does not use uv. To work against an unreleased
SDK, install it from a checkout of the ankka repository instead, by path (uv add --editable
/path/to/ankka/sdks/python), after generating its protocol stubs as described under
Developing the SDK.
It depends on grpcio and protobuf. The testkit extra adds testcontainers and httpx, which the
integration testkit needs to start Postgres and the sidecar image. Docker is needed for integration tests
and for running the sidecar locally; a JVM is not.
The shape every component shares¶
A component is a class that extends the kind's base class and declares, as class attributes, its
component_id and its codecs. Handlers are methods decorated with their wire name:
class ShoppingCartEntity(EventSourcedEntity[ShoppingCart, ShoppingCartEvent]):
component_id = "shopping-cart"
state_codec = json_codec(ShoppingCart, "shopping-cart")
event_codec = json_codec(ShoppingCartEvent, "shopping-cart-event")
@command("add-item")
def add_item(self, item: LineItem) -> EventSourcedEffect[ShoppingCart, ShoppingCartEvent, Done]: ...
@query("get-cart")
def get_cart(self) -> ReadOnlyEffect[ShoppingCart, ShoppingCartEvent, ShoppingCart]: ...
A handler's argument and reply types are read from its annotations and encoded with the SDK's default
codec: dataclasses and unions of dataclasses as JSON, primitives as text. json_codec(cls, "manifest")
names the codec for a state, event or row type explicitly. A mistake in a class, such as a query that does
not return a read-only effect, raises RegistrationError when the class is defined or registered.
Field names are the wire contract. The JSON the SDK writes is the JSON the Scala SDK writes, so use the same
field names, productId rather than product_id, when a Scala service shares the data.
Event sourced entity¶
| Part | API |
|---|---|
| Base class | EventSourcedEntity[S, E] |
| Class attributes | component_id, state_codec, event_codec |
| Must define | empty_state(self) -> S, apply_event(self, state, event) -> S |
| Decorators | @command("name"), @query("name") |
| In a handler | self.state, self.entity_id, self.context (a CommandContext), self.effects |
| Effects | persist(e, *more), persist_all(events), delete_entity(), then .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(), .delete_entity(), .expire_after(timedelta); reply(r), error(msg, code), no_reply() |
| Types | A command returns EventSourcedEffect[S, E, R]; a query returns ReadOnlyEffect[S, E, R] |
| Replication | replicated = True; effects hand_over_to(installation), replicate_to(installation), stop_replicating_to(installation), alone or after persist; self.context.installation, self.context.is_home; self.event_context (installation, is_local, sequence) in apply_event; EventSourcedTestKit.of(cls, id, mode="follower", home=name, installation=name) and kit.replay(*events, written_at=name) |
See Event sourced entities and Replicated entities.
Key value entity¶
| Part | API |
|---|---|
| Base class | ankka.key_value_entity.KeyValueEntity[S] |
| Class attributes | component_id, state_codec |
| Must define | empty_state(self) -> S |
| Decorators | @command("name"), @query("name") |
| In a handler | self.state, self.entity_id, self.context, self.effects |
| Effects | update_state(s), delete_entity(), then .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(), .expire_after(timedelta); reply(r), error(msg, code), no_reply() |
See Key value entities.
View¶
| Part | API |
|---|---|
| Base class | ankka.view.View[Src, Row] |
| Class attributes | component_id, source (an entity or workflow class) or topic (a topic name), event_codec, row_codec; optionally version, and declared queries as query(name, statement) from ankka.view |
| Must define | on_change(self, event) -> ViewEffect |
| May override | on_delete(self) -> ViewEffect, which deletes the row by default |
| In a handler | self.row (the current row or None), self.metadata (subject, sequence_number), self.standing (a workflow source's Standing, else None), self.effects |
| Effects | update_row(row), delete_row(), ignore() |
| Querying | client.views.get(view_id, key, RowType), client.views.all(view_id, RowType), client.views.ask(view_id, name, RowType, {"value": ...}, limit=None) |
| Table name | table_of(component_id), for a statement to name |
Keyed view¶
| Part | API |
|---|---|
| Base class | ankka.keyed_view.KeyedView[Row] |
| Class attributes | component_id, row_codec; optionally version and declared queries |
| Sources | a method per source marked @on(EntityClass, event_codec); optionally @on_deleted(EntityClass) |
| In a handler | self.subject, self.metadata, self.rows.get(key), self.rows.ask(name, ...), self.effects |
| Effects | update_row(key, row), delete_row(key), update_rows(...), delete_rows(keys), ignore(), combined |
| Testing | KeyedViewTestKit.of(View) |
See Views.
Consumer¶
| Part | API |
|---|---|
| Base class | ankka.consumer.Consumer[Src, Out] |
| Class attributes | component_id, source or topic, message_codec; for a topic, start_from, contract, broker, parallel; to publish, produces_to (a topic, or a Publication with its contract and broker) and out_codec |
| Must define | async on_message(self, message) -> ConsumerEffect |
| May override | on_delete(self), which ignores by default |
| In a handler | self.metadata (subject, sequence_number), self.standing (a workflow source's Standing, else None), self.installation, self.local_origin, self.client, self.effects |
| Replicated sources | mirrored_changes = True gives a consumer that publishes changes written at other installations too |
| Effects | produce(out, metadata=None), produce_all(messages), done(), ignore() |
| One of several messages | self.effects.message(out, key=None, metadata=None); key is a record key other than the message's subject |
Delivery is at least once. A consumer that produces needs ANKKA_KAFKA_BOOTSTRAP_SERVERS on the sidecar.
produce_all publishes its messages in order, and the change is handled when the broker has accepted all
of them; an empty list is handled at once. See Consumers.
Graph consumer¶
A consumer that publishes its source as graph deltas. It is registered as a consumer and can publish nothing but deltas.
| Part | API |
|---|---|
| Base class | ankka.GraphConsumer[Src], in ankka.graph |
| Class attributes | component_id, source or topic, message_codec, produces_to; contract, broker, parallel for a topic; no out_codec |
| Must define | on_message(self, message) -> GraphEffect, which may be async |
| May override | on_delete(self), which ignores by default |
| In a handler | self.metadata, self.client, self.graph, self.effects |
| Elements | self.graph.node(id, labels=(), properties=None, version=None), edge(id, type=, from_id=, to_id=, properties=None, version=None), tombstone_node(id, version=None), tombstone_edge(id, type=, from_id=, to_id=, version=None) |
| Effects | self.effects.publish(elements), done(), ignore() |
| A refusal | graph.RefusedElement, a ValueError whose why names the rule broken |
In ankka.graph |
What it is |
|---|---|
Element |
A frozen dataclass: kind, element, id, version, labels, type, from_id, to_id, properties, and key, the record key |
read(value, key=None) |
Reads a record's value into an Element; given a key, also checks it is the delta's element key. Raises ValueError naming the fault. |
CODEC |
The delta codec, under the manifest ankka.graph-delta.v1: a message_codec for a consumer that reads a delta topic |
node_key(id), edge_key(id), SCHEMA_NAME |
The element keys and the contract's name |
A property value is a str, an int, a float, a bool, or a list of one of those; a bool is not an
integer. See Publish a graph.
Workflow¶
| Part | API |
|---|---|
| Base class | ankka.workflow.Workflow[S] |
| Class attributes | component_id, state_codec, optionally settings |
| Must define | empty_state(self) -> S |
| Decorators | @command("name"), @query("name"), @step("name") from ankka.workflow |
| In a handler | self.state, self.entity_id, self.context (with client), self.effects, self.step_effects |
| Command effects | update_state(s), transition_to(step, input=None), then .then_transition_to(step, input), .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(); reply(r), error(msg, code) |
| Step effects | update_state(s) then, or directly: transition_to(step, input), pause(after=None, on_timeout=None), end(), fail(msg, code) |
| Settings | WorkflowSettings(timeout, default_step_timeout, default_recovery, steps={name: StepSettings(timeout, recovery)}) |
| Recovery | Recovery(max_retries=n, failover_to="step") |
Steps are async and may call other components. A step that raises is retried as its recovery says, then
failed over. See Workflows.
Timed action and timers¶
| Part | API |
|---|---|
| Base class | ankka.timed_action.TimedAction |
| Class attributes | component_id |
| Decorators | @action("name") from ankka.timed_action |
| In a handler | self.metadata (the timer's name and attempt count), self.due_time (the due time the run is for, a UTC datetime), self.client, self.effects |
| Effects | done(), fail(msg, code) |
| Scheduling | await client.timers.schedule(timer_id, timedelta, component_id, action, input), await client.timers.schedule_recurring(timer_id, delay, period, component_id, action, input), await client.timers.cancel(timer_id) |
Scheduling twice under one id replaces the earlier timer, except that a recurring timer set again for the same handler with the same period keeps its next due time. See Timers.
Agent¶
| Part | API |
|---|---|
| Base class | ankka.agent.Agent |
| Class attributes | component_id, tools ({name: Tool(description, function, InputDataclass, approval=…)}), guardrails ({name: Guardrail(check)}), optionally role, max_tool_call_steps, mcp_servers ({name: McpServer(url=…, service=…, project=…, path=…, headers={header: "ANKKA_MCP_…"}, approval=…)}), result_guardrails ({name: ResultGuardrail(check)}) |
| Approval | approval=True, or Approval(within=timedelta(…)) for a time limit |
| Calling | client.for_agent(id, session).call(name): await .ask(input, reply=T) → Answered(value) or AwaitingApproval(requests); await .decide(approval_id, approved=…, by=…, note=…), answered as ask; .stream_parts(input); .invoke and .stream raise ApprovalAwaited |
| Decorators | @command("name"), @stream("name") from ankka.agent |
| In a handler | self.session_id, self.metadata, self.client, self.effects |
| Effects | system_message(t), user_message(t), model(name), then .with_model(name), .with_context(t), .memory(bool), .tools(*names), .guardrails(*names), .then_reply(), .then_reply_json(); error(msg, code) |
A handler returns a plan; the sidecar runs the model loop, calls tools back in the process with the model's
arguments, and checks guardrails. A guardrail's check takes the stage ("input" or "output") and the text,
and returns None to pass or a reason to block; a result guardrail's takes the MCP tool's name and what it
answered. The sidecar connects to the MCP servers and enforces approval, so the process never runs a server's
tool nor a tool that awaits a decision. A runtime before protocol 1.11 answers decide with a CommandError
saying it is too old. The unit kit takes scripted servers, AgentTestKit.of(Agent, mcp={server: {tool: fn}}),
and its reply's awaiting lists what a turn waits on; kit.decide(approval_id, approved, by, note) goes on.
See Agents and MCP servers.
Autonomous agent¶
| Part | API |
|---|---|
| Task type | TaskType(name, description, result=Dataclass, rules=(TaskRule(name, check),)); a check returns Accepted() or Rejected(reason) |
| Base class | ankka.autonomous.AutonomousAgent |
| Class attributes | component_id, description, accepts = [TaskAcceptance(task_type, max_iterations=n)], optionally instructions, tools, guardrails, model, settings, mcp_servers, result_guardrails |
| In a tool | self.client, self.task_id |
| Tasks | await client.tasks.create(task_type, instructions, id=…, attachments=[…], depends_on=[…]) |
| A task | client.for_task(id): await .get(task_type), .wait(task_type, timeout), .cancel(reason) |
| An instance | client.for_autonomous_agent(Agent, instance_id): .assign(*ids), .suspend(), .resume(), .terminate(), .state() (with awaiting), .notifications(), .decide(approval_id, approved, by, note=None) |
| One task | await client.for_autonomous_agent(Agent).run_single_task(task_type, instructions) |
| Testing | AutonomousAgentTestKit.of(Agent): run_tool, check_result, check_guardrail; the integration kit's await_task and notifications, with ANKKA_MODEL_SCRIPT turns that may carry when or when_tool_result |
The sidecar runs the loop and keeps every record; the process runs the tools, checks guardrails, and checks a result against its task type and rules. See Autonomous agents.
HTTP endpoint¶
| Part | API |
|---|---|
| Base class | Endpoint |
| Class attributes | prefix, acl (required: Acl.ALLOW_ALL, Acl.DENY_ALL, Acl.AUTHENTICATED or Acl.allow_callers(...)) |
| Named events | an @sse handler may yield SseEvent(name, value) among its text, sent as an event of that name with the value as JSON (protocol 1.11) |
| Decorators | @get, @post, @put, @patch, @delete, @sse, @socket, each with a path template and an optional acl= for that route alone |
| A socket | the handler's parameter annotated Socket: async for text in socket, await socket.receive() (None once closed), await socket.send(text), which raises SocketClosed once closed; testing, EndpointTestKit.socket(path, frames) answers what the handler sent and how it ended |
| Handlers | async methods; path parameters bind by name, one further typed parameter is the body, the return value is encoded by its type |
| Callers | Callers.internet, Callers.service(name, project=None), Callers.any_in_project, Callers.self_, Callers.granted; a request's caller is Gateway, ServiceCaller, MachineCaller or LocalCaller |
| In a handler | self.request: query_param, query_params, header, principal (subject, name, email, email_verified, roles, claims, issuer), metadata, caller — a Gateway, ServiceCaller(project, name) or LocalCaller |
| Errors | raise HttpProblem(status, message); a CommandError from a call answers with its code's status |
The constructor receives the component client when it takes one. The process never binds an HTTP port: the
sidecar serves the routes and forwards each request. acl is required, as it is in the Scala SDK: a class
that omits it raises RegistrationError when it is defined, naming the class, rather than serving requests
under a posture nobody chose. A route decorator's acl= replaces the endpoint's for that route; a decorator
that omits it leaves the endpoint's in force. See HTTP endpoints.
Calling components¶
cart = client.with_metadata(self.request.metadata).for_event_sourced_entity("shopping-cart", cart_id)
await cart.call("add-item").invoke(item, reply=Done)
state = await cart.call("get-cart").invoke(reply=ShoppingCart)
| Target | Call |
|---|---|
| Event sourced entity | client.for_event_sourced_entity(component_id, entity_id) |
| Key value entity | client.for_key_value_entity(component_id, entity_id) |
| Workflow | client.for_workflow(component_id, workflow_id); .await_end(timeout, reply=State) waits for its end, .await_end_parts(timeout, reply=State) as parts for an @sse route, and .call(name).then_await_end(timeout).invoke(input, reply=State) starts and waits as one call |
| Agent | client.for_agent(component_id, session_id) |
.call(name) gives an invocation: await invocation.invoke(input, reply=Type), or
async for token in invocation.stream(input) for a streaming agent handler. A refusal raises
ankka.client.CommandError, whose error holds the message and code. Passing a request's metadata on
makes the call a child span of the request's trace. See Calling components.
Secret store¶
The service's secret store, for a value it must keep and never record, such as a credential a person
gave it (Secrets a service keeps). An endpoint, a consumer, a graph consumer, a
timed action, an agent and an autonomous agent reach it as self.secrets, and a workflow in a step; an
entity's CommandContext and a View have no secrets at all, and a workflow's command handler is
refused. await self.secrets.put(name, value), await self.secrets.get(name) (the value, or None) and
await self.secrets.delete(name). A refusal is a CommandError with the runtime's code.
The runtime beside the process holds the store and its key; the process never sees the key. For a unit
test, assign component.secrets = InMemorySecrets(), which applies the runtime's rules. The integration
test kit puts a generated ANKKA_SECRET_KEY on the runtime it starts; env={"ANKKA_SECRET_KEY": ""}
starts one with none. Needs protocol 1.4: an earlier runtime is reported as too old for the store.
Other services¶
An endpoint, a consumer, a graph consumer, a timed action, an agent and an autonomous agent call another
service as this one through self.services, and a workflow in a step; an entity's CommandContext and a
View have none. self.services("orders") or self.services("invoices", project="billing") answers a
client with await get(path, returns), get_text(path), post(path, body, returns),
put(path, body, returns), delete(path) and request(method, path, body=, content_type=, headers=).
No answer is ServiceUnresolvable, ServiceIdentityMismatch or ServiceUnanswered; an answer outside 2xx
to a typed helper is ServiceCallFailed; all four are ServiceError. For a unit test, assign
component.services = ScriptedServices(). Needs protocol 1.8: an earlier runtime is reported as too old
to call another service. See Calling other services.
Running a service¶
import asyncio
from ankka import Ankka
service = (
Ankka.service()
.register(ShoppingCartEntity)
.register(ShoppingCartEndpoint)
)
asyncio.run(service.listen())
listen() serves the protocol on port 9010, or ANKKA_PROCESS_PORT, and waits for the sidecar to connect.
Locally, run the sidecar with docker compose --profile polyglot up -d from the ankka repository.
Testing¶
| Kit | Module | Drives |
|---|---|---|
EventSourcedTestKit.of(Cls, id) |
ankka.testkit |
One entity; call(name, input) returns events, state and reply. |
KeyValueTestKit.of(Cls, id) |
ankka.testkit |
One key value entity. |
WorkflowTestKit.of(Cls, id) |
ankka.testkit |
One workflow: call, run_step, run_until_end. |
ViewTestKit.of(Cls) |
ankka.testkit |
A view's on_change and on_delete, and the resulting rows. |
ConsumerTestKit.of(Cls) |
ankka.testkit |
A consumer's on_message(message, subject, sequence=None) and on_delete. produced holds each payload it published and messages each Produced(payload, key, metadata). |
GraphConsumerTestKit.of(Cls, client=None) |
ankka.testkit |
A graph consumer's on_message(message, subject, sequence=1) and on_delete(subject, sequence=1), each returning the Elements published, read back from their bytes. |
TimedActionTestKit.of(Cls) |
ankka.testkit |
A timed action's handlers. |
AgentTestKit.of(Cls, session_id, model=ScriptedModel()) |
ankka.testkit |
An agent's plan, tools and guardrails, against a scripted model. |
EndpointTestKit.of(Cls, …) |
ankka.testkit |
An endpoint's routes, with no sidecar. |
AnkkaTestKit.start(builder) |
ankka.testkit.integration |
The whole service through the real sidecar image and a throwaway Postgres. restart() starts a new sidecar on the same database; kit.caller_headers(caller) makes a request to kit.http arrive as that caller, so a route's caller rules apply. |
Unit testkits need nothing running and still round-trip every value through its codec. See Testing.
Developing the SDK¶
From sdks/python in the ankka repository:
uv sync # install, with the development dependencies
uv run python scripts/proto.py # copy the protocol in and regenerate the gRPC stubs
uv run pytest # unit testkits, the encoding fixtures, the servicer
uv run pytest -m slow # through the real sidecar and Postgres; needs Docker
uv run mypy && uv run mypy examples # strict type checking
uv run conformance # serve the reference service and run the platform's conformance suite
uv run python -m examples.shopping_cart.main # the sample, beside `docker compose --profile polyglot up`
The SDK carries a copy of the protocol so it can be built on its own; scripts/proto.py refreshes it. The
conformance suite needs sbt and runs the platform's suite against the SDK's reference service. See
Adding a language SDK.