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.
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:
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.
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):
billing_view = await client.config.get("billing")
print(billing_view["app_name"]) # resolved from commonLive 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.
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 valueChange 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).
@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:
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:
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.
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.
async with AsyncSmplClient(environment="production", service="my-service") as client:
await client.wait_until_ready()
# safe to fire writes that you expect to broadcast backThe 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.
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
- Config Management — Create and manage configs programmatically
- Smpl Flags — Control feature rollouts with rules-based targeting
- API Reference — Config — Full REST API documentation

