Extension points
Four interfaces are meant to be implemented by code outside Pyrula: the store ABCs, LLMClient,
MemoryBackend and ScheduleBackend. They carry the extension tier on the
stability page. The tier makes one promise about the future: an
implementation that works today keeps working after a minor release adds to the interface.
The rule
Section titled “The rule”The abstract core of each interface is frozen at the first tag. After the tag:
- A method added to an extension point is never abstract. It ships with a default that raises
NotImplementedErroror degrades to a documented behavior, and Pyrula calls it only after a capability check. - A keyword added to an existing method is passed only to implementations whose signature takes
it. You never need
**kwargsto survive an upgrade. - A change to a signature or a frozen method goes through the deprecation rule: a minor release warns, the next one removes.
Before the tag, nothing is published, so the core can still change. A method stays in the core when the engine calls it on every use of the feature its mixin claims and has no safe degrade. Demoting such a method would only move a startup error into the middle of a run.
Stores
Section titled “Stores”Store combines the ABCs in pyrula.contracts.stores. Implement the mixins you need and pass the
result where a store goes (CompositeStore joins separate lifecycle and event-log backends).
| ABC | Abstract core | Notes |
|---|---|---|
LifecycleStore | 23 methods (submit, claim, status, pause and cancel controls, results, leases) | fail_interrupt, hand_back_run, terminate_run, apply_retention, submitted_at, get_pause_requested_at, note_replay_check_timeout, the run-version methods (get_run_version, adopt_run, register_version, version_rank, get_run_rank) and the binding methods (get_store_binding, bind_store, clear_store_binding) have defaults; see capabilities |
EventLogStore | append_event, load_run, replay_run, get_last_event, read_entries_from | SequencedEventLogStore adds append_event_sequenced |
IdempotencyStore | get_idempotency_run, set_idempotency_run | |
CacheStore | cache_get, cache_set | |
KVStore | kv_get, kv_put, kv_delete, kv_list, kv_search | |
TimerStore | set_sleeping, get_sleeping_runs, wake_run, wake_due | reopen_wait and time_out_wait are defaults |
SignalStore | send_signal, peek_signal_or_arm, pop_signal_match | |
UpdateStore | the five update methods, deliver_update through read_update_result | |
ChainStore | the seven chain methods, get_chain_tail through read_inbox_seal |
SignalStore, UpdateStore, ChainStore and the sleeping core of TimerStore stay abstract
because a store that opts into the feature must provide all of it. A store that does not need
signals, updates or chains does not inherit those mixins, so the gap shows up when you build the
store rather than in the middle of a run.
These methods are defaults that raise NotImplementedError or degrade, each behind a capability:
| Method | Capability | Without it |
|---|---|---|
TimerStore.reopen_wait | wait_reopen | The attempt parks without re-journaling the opening |
TimerStore.time_out_wait | wait_timeout | The holder does not close its own wait; the timer does |
LifecycleStore.fail_interrupt | interrupt_fail | The failure is logged and the run stays parked |
LifecycleStore.hand_back_run | hand_back | A run the worker declines (a journal or version it cannot read) is released and left to lease recovery, which counts an attempt; logged once |
LifecycleStore.terminate_run | terminate | A stuck run cannot be ended from outside; the call raises NotImplementedError |
LifecycleStore.get_pause_requested_at defaults to reading get_run_controls, and the module
function pyrula.workflows.continuation.resolve_continuation follows run:continued events without the tail record or seals when the
store lacks chains. clear_pause is not part of any interface. Resume a paused run with
resume_paused_run.
Capabilities
Section titled “Capabilities”store.capabilities() returns a frozenset[str] naming the optional groups a store provides. The
default on LifecycleStore reports a group when the class overrides every method in it.
store_capabilities(store) in pyrula.contracts.stores is the one place Pyrula asks: it calls
capabilities() when the store has it, and otherwise checks for the methods, so a duck-typed store
still works.
| Capability | Methods |
|---|---|
timers | set_sleeping, get_sleeping_runs, wake_run, wake_due |
signals | send_signal, peek_signal_or_arm, pop_signal_match |
updates | deliver_update, peek_pending_updates, ack_pending_update, write_update_result, read_update_result |
chains | the seven chain methods |
wait_reopen | reopen_wait |
wait_timeout | time_out_wait |
interrupt_fail | fail_interrupt |
run_versions | get_run_version, adopt_run, register_version, version_rank, get_run_rank |
store_binding | get_store_binding, bind_store, clear_store_binding |
hand_back | hand_back_run |
terminate | terminate_run |
schedules | schedule_due, schedule_claim_fire, plus the rest of ScheduleBackend |
projection | drain_projection_pending |
maintenance | run_maintenance |
The set of names is open. A name Pyrula does not know reads as absent, and a later release adds
new capabilities as new strings. CompositeStore, ArchivingStore and the agents store adapter
forward capabilities() to the backend that serves each group. A wrapper you write that holds
another store overrides capabilities() the same way, or its inner store’s capabilities read as
absent.
A store without schedules never fires schedules. When a worker has schedules registered and the
store lacks the capability, it logs one warning at start.
maintenance is for a store that has housekeeping to do, such as deleting runs past their
retention. The worker calls run_maintenance() once at start and then again within the number of
seconds the call returns, and never less often than orphan_scan_interval. A StoreUnavailable
from it is retried on the next tick. A ConfigurationError means the store has found it must not
be used, for example because another host is writing its file: it ends the loop, the workflow
worker stops and the agent runtime reports itself not ready. A worker on a store
without the capability logs one INFO line at start. The Valkey and memory stores do not offer it,
because Valkey expires its own keys and the memory store keeps nothing.
Prove it with the contract kits
Section titled “Prove it with the contract kits”pyrula.workflows.testing ships a pytest contract class per interface (LifecycleStoreContract,
EventLogStoreContract, TimerStoreContract, SignalStoreContract, UpdateStoreContract,
ChainStoreContract, KVStoreContract, CacheStoreContract, IdempotencyStoreContract and
others). Subclass the ones you implement and provide a store fixture. A test for an optional
capability is skipped, not failed, when store_capabilities(store) lacks it, so a store that
implements only the core passes the required tests and reports the rest as skipped.
EnvelopeContract covers what a store does to an event it did not receive from a caller. A store
must persist headers on every event it writes, including the ones it builds itself (a wake, a
wait timeout, a resume, an unpause, a failed interrupt, a terminate, a requeue and the cancel and
pause audit events). The engine decides those headers: call engine_headers(kind) from
pyrula.workflows.stores.event_headers (or build the event with the engine’s builders, which do)
and write what it returns. It is empty today; the contract runs under stamped_engine_headers so
a store that drops or never asks for them fails now, not when the engine starts stamping.
A reopened wait is the exception: it copies its opening’s headers except encoding
(reopened_headers), because the copy’s payload is re-encoded as JSON. A store must also return
an update payload exactly as it was sent: store the JSON verbatim and never decode and re-encode
it, which turns a 19-digit integer into a float and [] into {} in some server-side JSON
libraries.
LLM providers
Section titled “LLM providers”LLMClient in pyrula.agents.llm.base has two abstract methods, stream and complete, and both
are frozen. A provider that implements only those works under every agent. tool_choice on
stream is the one optional keyword today: when a provider’s stream does not take it, a forced
tool call goes out unforced and Pyrula logs that once. New optional keywords follow the same probe,
accepts_keyword in pyrula.contracts.probe.
The model on an llm:done event is the model that answered the call. It matches the configured
model today, and it is the field to read if a fallback ever answers with another.
Memory backends
Section titled “Memory backends”MemoryBackend in pyrula.agents.memory is a synchronous protocol with put, get, delete,
list and search. The method set and signatures are frozen. search ranks however the backend
defines relevance, and Pyrula does not assume more than that. Vector search will arrive as a
separate optional protocol that Pyrula finds by method presence, not as new keywords on search.
Schedule backends
Section titled “Schedule backends”ScheduleBackend in pyrula.workflows.schedules.store lists the methods a store implements to
fire schedules: schedule_create, schedule_get, schedule_list, schedule_update,
schedule_advance, schedule_patch, schedule_delete, schedule_due and
schedule_claim_fire. Support is the schedules capability. The protocol is never checked with
isinstance, so a method added to it later does not stop existing stores from firing. Pause,
resume and run now need schedule_patch on top; a store without it fires schedules but does
not offer those controls.
Open enums
Section titled “Open enums”RunStatus, ErrorCode (both the workflow and the agents version), StepKind, EventKind (core
and agents) and RunKind are StrEnums, and each is an open set. A release may add members.
Compare by value, give every branch on one of them a fallback, and never use an exhaustive match.
RunEvent.kind is a plain str: it can hold a kind no enum lists, including your own from
ctx.emit.
The same applies on the wire. A consumer of the event stream or of server-sent events ignores
unknown kinds and unknown fields. On the live stream, a frame without an id: line is ephemeral: it has no stored
position, so a reconnect with Last-Event-ID does not replay it. The /replay endpoint is the
exception to read that rule by: it sends stored, durable events and sends them without id:
lines, so a missing id there says nothing about durability and a client resumes it by asking again.
MCP servers
Section titled “MCP servers”build_mcp_server returns a DurableMCPServer. Its sdk_server property is the MCP SDK’s own
server object, whose type belongs to the SDK and is not guaranteed across versions (FastMCP on
mcp 1.x, MCPServer on 2.x), so annotate it Any.