Skip to content

Config Runtime

The runtime API is the day-to-day SDK experience for reading configuration values and reacting to changes. Configs are created via the Console UI or the management API; the runtime resolves values, picks up live updates over WebSocket, and fires change listeners as values shift.

The primary entry point is bind() — hand it a config ID and a model or dict of defaults, and the SDK returns a live, code-first view of the resolved values for the active environment. Any value you declare locally flows up to the Console so customers can override it without a code change; any value the customer sets flows back down into your bound object automatically. For tooling, scripts, and one-off reads, get() is also available.

The first runtime call triggers lazy initialization — the SDK fetches all configs, resolves inheritance and environment overrides into a local cache, and opens a shared WebSocket for live updates. Subsequent reads hit the cache.

Bind Models or Dicts

bind(id, target) registers your defaults with the Console and returns a live, updatable view backed by the local cache. Every read sees the latest server-pushed state, no separate subscribe call required.

python
import asyncio
from pydantic import BaseModel, Field
from smplkit import AsyncSmplClient

class Database(BaseModel):
    host: str = "localhost"
    port: int = 5432
    pool_size: int = 5

class UserServiceConfig(BaseModel):
    database: Database = Field(default_factory=Database)
    cache_ttl_seconds: int = 300
    enable_signup: bool = True

async with AsyncSmplClient(environment="production", service="user-service") as client:
    cfg = await client.config.bind("user-service", UserServiceConfig())
    print(cfg.database.host)
    print(cfg.cache_ttl_seconds)

When you don't have a model class on hand, bind a plain dict or map — the same live-view semantics apply:

python
db = await client.config.bind(
    "database",
    {
        "primary": {"host": "localhost", "port": 5432},
        "pool_size": 10,
    },
)
print(db["primary"]["host"])
print(db["pool_size"])

Inheritance

Pass the parent binding when you call bind() to set up an inheritance relationship. Keys defined in the child win where both define them; keys absent from the child fall through to the parent (and transitively up the chain). The built-in common config exists as a convenient shared parent — use it for defaults you want every config to inherit.

python
common = await client.config.bind("common", {"app_name": "Acme SaaS"})
billing = await client.config.bind(
    "billing",
    {"plan": {"max_seats": 5}},
    parent=common,
)
print(billing["plan"]["max_seats"])

The bound child mirrors only the keys it declared locally. To see the full resolved view (child's own values + everything inherited from the parent chain), use get(id):

python
billing_view = await client.config.get("billing")
print(billing_view["app_name"])  # resolved from common

Live Updates

Bound objects are updated in place. Hold onto the reference and subsequent reads always see current values. After a Console UI write or a management-API change, the WebSocket pushes the new value into the local cache and the next read on the same object picks it up.

python
cfg = await client.config.bind("user-service", UserServiceConfig())
print(cfg.cache_ttl_seconds)   # 300 — local default

# ...someone updates cache_ttl_seconds to 600 via Console UI or management API...
await asyncio.sleep(0.2)        # brief pause for the WS event to arrive

print(cfg.cache_ttl_seconds)   # 600 — same object, updated value

Change Listeners

Register callbacks that fire when config values change. Three scoping levels are available: global (any config), config-scoped (specific config), and item-scoped (specific item within a config).

python
@client.config.on_change
def on_any_change(event):
    print(f"{event.config_id}.{event.item_key}: "
          f"{event.old_value!r} -> {event.new_value!r}")

@client.config.on_change("billing", item_key="plan.max_seats")
def on_max_seats(event):
    print(f"max_seats changed to {event.new_value}")

event.source is "websocket" when the change came from a server push and "manual" when it came from a refresh() call.

Look Up Values Without Binding

For tooling, scripts, or quick reads that don't need a bound model, resolve values directly with get().

get(id) returns a live, dict-like view of all resolved values for the active environment — useful for inspecting configs you didn't define locally:

python
cfg = await client.config.get("user-service")
print(cfg["database.host"])
for key, value in cfg.items():
    print(key, value)

get(id, key, default) returns a single value and registers the key for code-first observability in the Console — handy for debugging which keys your code actually reads:

python
ttl = await client.config.get("user-service", "cache_ttl_seconds", default=300)
print(ttl)

Calling get(id, key) without a default raises NotFoundError if the key is missing.

Manual Refresh

refresh() re-fetches all configs, re-resolves values, and fires change listeners for any values that differ. WebSocket events trigger this automatically in production; call refresh() manually after suspecting drift, in short-lived scripts, or in tests.

python
await client.config.refresh()

In Python, WebSocket events trigger automatic refresh. Use await asyncio.sleep(...) to allow the event to arrive in tests rather than calling refresh() manually.

waitUntilReady

If your code creates a client and immediately fires a management write that you expect to observe via change listeners, call waitUntilReady() first. Without it, the write can race the WebSocket subscribe and the SDK silently misses its own broadcast.

python
async with AsyncSmplClient(environment="production", service="my-service") as client:
    await client.wait_until_ready()
    # safe to fire writes that you expect to broadcast back

The same method exists in every SDK: client.wait_until_ready() (Python), client.waitUntilReady() (TS / Java), client.WaitUntilReady(ctx, 0) (Go), await client.WaitUntilReadyAsync() (C#). Production code that has been running for a while doesn't need it — only the boot-then-write-then-listen pattern does.

Sync Client (Python)

For synchronous applications (Django, Flask, CLI tools), use SmplClient instead of AsyncSmplClient. The API is identical but without await.

python
from smplkit import SmplClient

with SmplClient(environment="production", service="user-service") as client:
    cfg = client.config.bind("user-service", UserServiceConfig())
    print(cfg.database.host)

    @client.config.on_change("user-service", item_key="cache_ttl_seconds")
    def on_change(event):
        print(f"cache_ttl_seconds changed to {event.new_value}")

Next Steps