Skip to content
Talk to our solutions team

Platform Integration

Audience: platform developers — kis.ai engineers building or extending a service that consumes the config and discovery services. You have access to the kis.ai monorepo and the lib-chassis Go libraries.

You also need the HTTP API reference — the chassis libraries call exactly those endpoints under the hood, and when something misbehaves you’ll want to confirm against the wire protocol. This doc covers everything the libraries do on top of the API.

The REST + SSE API is the source of truth and works from any language. The Go libraries (git.kis.ai/kis.ai/lib/chassis/config and git.kis.ai/kis.ai/lib/chassis/discovery) sit on top of that API and give kis.ai services:

CapabilityWhat you’d write by handWhat the library does
Typed accessjson.Unmarshal into your own structs per callsmap.OrderedMap returned from one of three getters; deep-key path access (cfg.Get("features.fast_path").Bool(false)); optional sugar helpers (ConfigBool, ClusterStringSlice, …).
Change callbacksHand-rolled SSE consumer + JSON decode loop + dispatchcc.OnChange("my-watch", func(ctx, evt) error { ... }). One handler per name; chassis fans out per-event.
Cache + resumeTrack Last-Event-Id, persist it, replay on reconnectbbolt write-through cache; cursor persisted across process restart; SSE consumer reconnects with exponential backoff.
Local fallbackIf remote is down, your code 500sReads fall through in-memory cache → LocalConfig fallback → empty. Synchronous reads NEVER block on the network.
Layered resolution (local mode)Re-implement the same merge algorithm you’d hit server-sideLocal-mode client supports the same cluster: + tenants: layered schema as the config service; same resolution rules. Dev tests don’t need a running config.svc instance.
Discovery clientSame SSE + REST work for /services/* + /stream/discoverydiscovery.Client with Register, Heartbeat, Deregister, Instances, WatchService.

If you’re writing kis.ai service code, you almost certainly want the libraries. The only reason to call the REST API directly from Go is in tooling / one-off admin scripts.

The config client exposes three distinct reads matching three distinct scopes of configuration:

MethodReturnsScope
GetConfig(ctx)Top-level bootstrap keys MINUS cluster: and tenants: blocks.Service-own — knobs that belong to this binary (log level, listener port, feature flags, OTel collector, etc.)
GetClusterConfig(ctx)The cluster: block, resolved for this service’s dc:cluster:service:version path.Cluster-wide — shared infra coordinates (kafka brokers, Jaeger endpoint, shared caches, etc.)
GetTenantConfig(ctx)The resolved *Tenant for the CPET in ctx.Per-tenant — multi-tenancy overrides for the current request’s tenant.

All three serve from the same client. The transport (remote SSE vs local file/dir) is determined by bootstrap YAML, not by the call.

The chassis client supports four modes; one bootstrap YAML drives all of them:

service: myservice # required; chassis identity
version: 1.0.0
sid: ${HOSTNAME}
host: "" # listener config (chassis-owned)
port: "8443"
zerotrust: true
log:
level: info
features: # service-own keys → returned by GetConfig()
new_dashboard: true
# --- one of these four mode selectors ---
# (a) Remote mode: SSEs against the config.svc binary
config_url: https://config.internal:7123
config_apikey: ${env:MYSERVICE_CONFIG_APIKEY}
# (b) Local-file mode: chassis loads cluster + tenants from a single YAML
# config: /etc/myservice/cluster.yaml
# (c) Local-dir mode: chassis walks a directory and merges *.yaml/*.yml
# config: /etc/myservice/config/
# (d) Bootstrap-inline mode: cluster + tenants live in this bootstrap
# cluster:
# kafka_brokers: ["kafka-0:9092", "kafka-1:9092"]
# tenants:
# - { key: acme:forge:prod:acme-prod, name: acme-prod, active: true,
# overrides: { log_level: debug } }

Precedence: config_url > config: > inline cluster: / tenants:. Setting both config_url and config: logs a warning and ignores config:.

When your service boots through svcboot.NewServerCommand, the chassis does this for you:

  1. Loads bootstrap YAML.
  2. Constructs the config.Client with RemoteURL (from config_url:) or LocalPath (from config:) — whichever is set — plus LocalConfig (always the bootstrap OrderedMap, used by GetConfig() and as a fallback for the other getters).
  3. Registers the client in the lib-zero DI container under <service>-config-v2.
  4. Init is lazy — never blocks on the network, never returns “remote unreachable” errors. The SSE consumer runs in the background with exponential backoff (1 s → 30 s cap). Reads serve from cache + local fallback immediately.

In your service:

import (
"git.kis.ai/kis.ai/lib/chassis/config"
"git.kis.ai/kis.ai/lib/zero/container"
)
cc, _ := container.ResolveTyped[config.Client]("myservice-config-v2")
func loadFeatureFlags(ctx context.Context, cc config.Client) bool {
// Direct path access on the returned map:
cfg, _ := cc.GetConfig(ctx)
return cfg.Get("features.new_dashboard").Bool(false)
}
// Or via the sugar helper:
func loadFeatureFlagsHelper(ctx context.Context, cc config.Client) bool {
return config.ConfigBool(ctx, cc, "features.new_dashboard")
}

The Config* helper family (in lib-chassis/config/helpers_config.go) covers common types: ConfigString, ConfigBool, ConfigInt, ConfigMap, ConfigStringSlice, ConfigMapSlice, ConfigInt64, ConfigHas. All zero-value on miss — use for known-present keys.

func kafkaBrokers(ctx context.Context, cc config.Client) []string {
return config.ClusterStringSlice(ctx, cc, "kafka_brokers")
}

The Cluster* helper family mirrors Config* but reads from GetClusterConfig() — i.e., the resolved cluster: block.

The chassis TenantMiddleware extracts CPET (customer, product, environment, tenant) from headers / JWT claims / mTLS SAN and puts them into ctx. Then:

func handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cc, _ := container.ResolveTyped[config.Client]("myservice-config-v2")
t, kerr := cc.GetTenantConfig(ctx)
if kerr != nil {
// ErrCodeTenantNotFound or ErrCodeTenantNotActive — return
// 403 to the caller.
http.Error(w, kerr.Error(), http.StatusForbidden)
return
}
// t.RawConfig is the merged overrides — your service-specific
// per-tenant knobs.
rateLimit := t.RequestLimit // ratelimit.Limiter
_ = rateLimit
}
cc.OnChange("myservice-watch",
func(ctx context.Context, evt svcclient.ChangeEvent) error {
switch evt.Key {
case svcclient.ChangeKeyCluster:
fresh, _ := cc.GetClusterConfig(ctx)
applyClusterConfig(fresh)
default:
// Tenant-level: evt.Key is the CPET key
t, _ := cc.GetTenantConfigByKey(ctx, evt.Key)
applyTenantConfig(t)
}
return nil
})

Callbacks fire from a per-handler goroutine so a slow handler doesn’t block others. They MUST be idempotent — false positives (callbacks where the value didn’t actually diverge) are allowed by the contract.

The chassis client is built for never-hard-fail semantics:

ScenarioBehavior
config_url set, config.svc binary down at bootInit returns nil. Reads serve from bbolt warm-cache if any; else fall through to bootstrap cluster:/tenants: fallback; else empty/NotFound. SSE consumer retries with exponential backoff, reconnects when remote returns, refreshes cache + fires change callbacks.
Remote disappears mid-flightExisting cache stays valid. New reads keep serving from cache. SSE reconnect handles the catch-up.
Cursor persisted across process restartYes — written to LocalCachePath (bbolt) on every snapshot. Restart picks up where you left off.
Remote sends full_resyncIn-memory cache cleared; subsequent snapshot events repopulate; reads during the gap fall through to LocalConfig.
Bad bootstrap YAML / disk errorInit returns an error — the only hard-fail mode.
LoadAllTenants returnsImmediately, with nil. Background fetches populate the cache as they complete.

Your code should be written assuming the result is “best-known- good,” not “authoritative live state.” For values that MUST exist before serving traffic, either:

  1. Declare them inline in the bootstrap YAML so the LocalConfig fallback has them, OR
  2. Block /ready returning 200 until you’ve seen the values via a change callback.

Mirror story for discovery.Client:

import (
"git.kis.ai/kis.ai/lib/chassis/discovery"
"git.kis.ai/kis.ai/lib/zero/container"
)
sd, _ := container.ResolveTyped[discovery.Client]("myservice-discovery")
// Register this instance
lease, err := sd.Register(ctx, discovery.LeaseRequest{
Service: "myservice",
DC: "us-west",
Cluster: "prod-cluster-01",
Addr: "10.0.1.5:8443",
TTL: 15 * time.Second,
})
defer sd.Deregister(ctx, lease.ID)
// Heartbeat in a background goroutine (chassis offers a helper to
// do this for you; see lib-chassis/discovery/keepalive.go).
// Look up other services
instances, _ := sd.Instances(ctx, "forge-meta")
// Watch
sd.WatchService(ctx, "forge-meta", func(evt discovery.Event) {
// upsert / down / expire events
})

URL configuration uses a separate bootstrap key — servicediscovery_url: — so the discovery client and config client can point at different endpoints in the future. Today they typically share :7123 (the same config.svc binary).

A “block” in kis.ai parlance is a kis.ai service — a binary that implements one or more product capabilities. Standard layout for a new block:

baas/myservice/
├── version/root.go # Name, Version, BuildCommit, ClientName
├── cmd/
│ ├── root.go # svcboot.NewServerCommand wiring
│ └── version.go # util.PrintVersion wrapper
├── internal/
│ └── server/ # your subsystems, builders, routes
└── main.go # cmd.SetEmbedded(Embedded); cmd.Execute()

The svcboot.ServiceConfig you pass to NewServerCommand is where you wire your service-specific behavior:

svcboot.NewServerCommand(svcboot.ServiceConfig{
Name: version.Name,
ClientName: version.ClientName,
Version: version.Version,
Use: "server",
DefaultCfg: "/etc/myservice/config.yaml",
PreStart: func(ctx context.Context, serviceCfg smap.OrderedMap) error {
// Build subsystems. Resolve config + discovery clients from
// the DI container (chassis registered them already).
// Stash state in a closure-captured struct that RegisterRoutes
// + OnShutdown also reference.
return nil
},
RegisterRoutes: func(ctx context.Context, r *httprouter.Router) {
// Mount your HTTP handlers on r. Chassis already mounted
// /health, /ready, /version — don't re-register.
},
CustomMiddleware: func(r *httprouter.Router, cfg smap.OrderedMap, name string) http.Handler {
// Optional. Default chain is router → TenantMiddleware →
// ContextBasedTimeoutMiddleware. Wrap with your own auth /
// request-id middleware on top.
return r
},
OnShutdown: func(ctx context.Context) {
// Drain workers, close stores, etc.
},
})

config.svc itself is a worked example. If you want to see a fully-realized service:

  • baas/config/cmd/root.go — svcboot wiring
  • baas/config/internal/server/{subsystems,routes,server}.go — Build / RegisterAll / Shutdown helpers
  • baas/config/version/root.go — identity constants
  • baas/config/main.gocmd.SetEmbedded(...) + cmd.Execute()

Conventions to follow:

  1. Service identity at top-level YAML keys. service:, version:, sid: are chassis-owned. Don’t nest them under a service: map — chassis overwrites it.
  2. Health endpoints belong to chassis. Never register /health, /ready, /version in your RegisterRoutes.
  3. Auth bypass for chassis probes. If you provide a CustomMiddleware, allow-list /health, /ready, /version so probes stay unauthenticated.
  4. SSE handlers must clear the per-request write deadline. Use http.NewResponseController(w).SetWriteDeadline(time.Time{}). Otherwise the chassis server-wide WriteTimeout kills long- lived streams.
  5. bbolt opens need a finite Timeout. Timeout: 0 blocks on stale flock; use 5 s and surface the failure.
  6. OTel via lib-zero/telemetry.Init — call it from PreStart when serviceCfg.Get("telemetry.collector") is non-empty.

You don’t need a running config.svc binary for unit tests. Build a local-mode client directly:

import (
"context"
"git.kis.ai/kis.ai/lib/chassis/config"
"git.kis.ai/kis.ai/lib/supermap/smap"
)
func TestThing(t *testing.T) {
cc, err := config.New(config.Options{
ServiceName: "myservice",
LocalConfig: smap.Of(
"log", smap.ToMSI(smap.Of("level", "debug")),
"cluster", smap.ToMSI(smap.Of(
"kafka_brokers", []any{"k0:9092"},
)),
"tenants", []any{
smap.ToMSI(smap.Of(
"key", "acme:forge:prod:acme-prod",
"name", "acme-prod",
"active", true,
"overrides", smap.ToMSI(smap.Of(
"log_level", "trace",
)),
)),
},
),
})
if err != nil { t.Fatal(err) }
if err := cc.Init(context.Background()); err != nil { t.Fatal(err) }
defer cc.Close(context.Background())
// Test against cc the same way your service code uses it.
}

For tests that exercise file-watch behavior, pass LocalPath pointing at t.TempDir() and write/modify files during the test.

KnobGoes inRead via
log.level for the entire fleet of this serviceTop-levelGetConfig
log.level per tenanttenants[].overrides.log_levelGetTenantConfig(ctx).RawConfig.Get("log_level")
Kafka brokers (shared across services in cluster)cluster.kafka_brokersGetClusterConfig
Feature flag for one serviceTop-level or cluster.features if cluster-scopedGetConfig / GetClusterConfig
Tenant API key / secretNEVER in config — use lib-vault
Per-customer SLA tiertenants[].overrides.sla_tiert.RawConfig.Get("sla_tier")
  • HTTP API — every endpoint, every payload. The libraries call these.
  • lib-chassis/config/options.goOptions struct, fully documented in code.
  • lib-chassis/config/client.go — the Client interface.
  • lib-chassis/config/helpers_config.go + helpers.go — the helper families.
  • lib-chassis/discovery/client.godiscovery.Client interface.