Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

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 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 NotImplementedError or 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 **kwargs to 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.

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).

ABCAbstract coreNotes
LifecycleStore23 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
EventLogStoreappend_event, load_run, replay_run, get_last_event, read_entries_fromSequencedEventLogStore adds append_event_sequenced
IdempotencyStoreget_idempotency_run, set_idempotency_run
CacheStorecache_get, cache_set
KVStorekv_get, kv_put, kv_delete, kv_list, kv_search
TimerStoreset_sleeping, get_sleeping_runs, wake_run, wake_duereopen_wait and time_out_wait are defaults
SignalStoresend_signal, peek_signal_or_arm, pop_signal_match
UpdateStorethe five update methods, deliver_update through read_update_result
ChainStorethe 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:

MethodCapabilityWithout it
TimerStore.reopen_waitwait_reopenThe attempt parks without re-journaling the opening
TimerStore.time_out_waitwait_timeoutThe holder does not close its own wait; the timer does
LifecycleStore.fail_interruptinterrupt_failThe failure is logged and the run stays parked
LifecycleStore.hand_back_runhand_backA 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_runterminateA 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.

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.

CapabilityMethods
timersset_sleeping, get_sleeping_runs, wake_run, wake_due
signalssend_signal, peek_signal_or_arm, pop_signal_match
updatesdeliver_update, peek_pending_updates, ack_pending_update, write_update_result, read_update_result
chainsthe seven chain methods
wait_reopenreopen_wait
wait_timeouttime_out_wait
interrupt_failfail_interrupt
run_versionsget_run_version, adopt_run, register_version, version_rank, get_run_rank
store_bindingget_store_binding, bind_store, clear_store_binding
hand_backhand_back_run
terminateterminate_run
schedulesschedule_due, schedule_claim_fire, plus the rest of ScheduleBackend
projectiondrain_projection_pending
maintenancerun_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.

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.

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.

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.

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.

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.

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.

View this page as Markdown