Skip to content

Config Runtime

The runtime API is the day-to-day SDK experience. 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 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.

Get Resolved Values

get(id) returns a live, dict-like proxy over the resolved values for the active environment. The proxy is the live subscription — every read sees the latest server-pushed state, no separate subscribe() call required.

python
import asyncio
from smplkit import AsyncSmplClient

async with AsyncSmplClient(environment="production", service="my-service") as client:
    cfg = await client.config.get("user-service")
    print(cfg["database.host"])
    print(cfg["max_retries"])
    print(cfg.get("cache_ttl_seconds"))     # dict-style .get with default
    for key, value in cfg.items():
        print(key, value)

Typed Resolution

Pass a model class as the second argument to get to project values through it. The proxy reconstructs the model from the latest values on each read, so attribute access type-checks against your model while still tracking live updates.

python
from pydantic import BaseModel

class Database(BaseModel):
    host: str
    port: int
    pool_size: int

class UserServiceConfig(BaseModel):
    database: Database
    cache_ttl_seconds: int
    enable_signup: bool
    max_retries: int = 3

cfg = await client.config.get("user-service", UserServiceConfig)
print(cfg.database.host)
print(cfg.cache_ttl_seconds)
print(cfg.max_retries)

The SDK builds a nested structure from flat dot-notation keys (database.hostcfg.database.host) before passing it to your model.

Live Updates

The proxy returned by get is identity-stable — hold onto it and reads always reflect current values. After a server-side write, the WebSocket pushes the new values into the cache and subsequent reads on the same proxy see them.

python
cfg = await client.config.get("user-service", UserServiceConfig)
print(cfg.max_retries)        # 3 — initial value

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

print(cfg.max_retries)        # 7 — same proxy, new 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}")

# Item-scoped via the live-proxy handle
common_cfg = await client.config.get("common")

@common_cfg.on_change("max_retries")
def on_retries_change(event):
    print(f"max_retries 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.

Inheritance

Configs that have a parent inherit from it (and transitively up the parent chain). Values defined in a child take precedence; anything not overridden falls through to the parent. A config with no parent is standalone — common is a built-in config you can use as a parent for shared defaults, but it is not applied automatically.

python
auth = await client.config.get("auth-module")
print(auth["session_ttl_minutes"])   # defined on auth-module
print(auth["mfa_enabled"])           # defined on auth-module
print(auth["app_name"])              # inherited from common

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="my-service") as client:
    cfg = client.config.get("user-service")
    print(cfg["database.host"])

    typed = client.config.get("user-service", UserServiceConfig)
    print(typed.database.host)

    @client.config.on_change
    def on_change(event):
        print(f"{event.config_id}.{event.item_key} changed")

Next Steps