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
| Getter | Environment | Implementations |
|---|---|---|
get_store() | STORE_BACKEND | FileStore, InMemoryStore, SupabaseStore |
get_file_storage() | STORAGE_BACKEND | LocalFileStorage, SupabaseFileStorage |
get_vision() | VISION_BACKEND | CodexVision, FixtureVision |
get_embeddings() | EMBEDDINGS_BACKEND | OpenAICompatEmbeddings, FixtureEmbeddings |
get_transport() | TRANSPORT_BACKEND | TelegramTransport, 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.