Swappable backends

Store, vision, transport and friends behind Protocols.

Storage, vision, transport, embeddings, transcription and the clock all sit behind typing.Protocol definitions, chosen from the environment.

from runspace.protocols import get_store, get_vision, get_file_storage

store  = get_store()          # FileStore | InMemoryStore | SupabaseStore
vision = get_vision()         # CodexVision | FixtureVision
files  = get_file_storage()   # LocalFileStorage | SupabaseFileStorage

Which implementation you get is decided by environment variables, so the same image runs in a sandbox and in production without a code branch.

The full set

GetterEnvironmentImplementations
get_store()STORE_BACKENDFileStore, InMemoryStore, SupabaseStore
get_file_storage()STORAGE_BACKENDLocalFileStorage, SupabaseFileStorage
get_vision()VISION_BACKENDCodexVision, FixtureVision
get_embeddings()EMBEDDINGS_BACKENDOpenAICompatEmbeddings, FixtureEmbeddings
get_transport()TRANSPORT_BACKENDTelegramTransport, FileInboxTransport
get_clock()RealClock, FrozenClock

The Supabase implementations are not re-exported from their packages, so importing the protocol layer never pulls in the Supabase SDK. Reach one directly — from runspace.protocols.store.supabase_store import SupabaseStore — if you need to name its type.

get_clock() exists so a test can decide what "now" is. Anything that reads the wall clock directly is untestable around midnight, month boundaries and schedules, and the bug only appears at the wrong hour.

There is also a conversations protocol — runspace.protocols.conversations, with in-memory and Supabase implementations — used directly rather than through a getter, since a host that wants it knows which it wants.

Resetting between tests

The getters are cached, so a test that changes the environment has to clear them:

from runspace.protocols import reset

monkeypatch.setenv("STORE_BACKEND", "memory")
reset()

Without that, the first test to build an adapter pins its choice for the whole session and every later test quietly reads the wrong backend.

Why this is worth the indirection

Two reasons that are really the same reason.

Tests run against in-memory and fixture implementations, so the suite needs no network, no database and no API key — which is what makes it fast enough that people run it before committing.

And a deployment that needs a different backend gets one by setting a variable, not by forking. The alternative — a real client imported at module scope — is the thing that makes a library impossible to test and impossible to adopt.

The seams that point outward

Some behaviour has to come from the application, not from the library, and Runspace refuses to guess:

response_filter: myapp.checks:reject_empty

response_filter names a callable the app owns. Runspace loads it by path and calls it without knowing what it checks. That is how application policy — which phrasings count as a non-answer, what a refusal looks like in your domain — stays in the application, where it can be specific, instead of being hardcoded into a shared layer where it would be wrong for everyone else.

The same shape appears for file storage and PDF rendering: set_file_storage_provider and set_pdf_renderer let the host process install its own implementation before anything reaches for the default.