Remote Config
Tier: Enterprise · feature flag remoteConfig
Remote Config lets a kiosk fleet pull its launcher config from a URL instead of getting a new one every time you republish through the Portal. Devices poll the endpoint on a schedule; when the served config changes, the launcher repaints on the next poll.
Use it when:
- You need to push a change to thousands of devices without asking your MDM to redeploy a profile.
- Different sites need different configs off the same MDM profile (your endpoint returns a different body per device based on request headers).
- You want to A/B test a layout — flip the served config on the fly, watch the fleet pick it up within a poll cycle.
1. Enable the feature
In the Portal Builder → Features tab → Enterprise → toggle Remote config. The flag must also be present in the device's license; talk to us if you don't see it after upgrading.
2. Build the endpoint
The launcher does:
GET https://your-server.example.com/launcher.json
If-None-Match: "<last-known etag>"
You return:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v42"
Cache-Control: no-store
{ ... a complete, schema-valid MobiLauncherConfig ... }
When the content hasn't changed, return 304 Not Modified (no body)
— the launcher fast-paths out and doesn't re-parse.
The body must validate through the full schema (same Zod validator
used by the Portal). Schema failures don't crash the launcher; the
last good config keeps running and a pollParseError event fires.
Minimum endpoint (FastAPI sample)
from fastapi import FastAPI, Header, Response
import hashlib, json, pathlib
app = FastAPI()
def current_config() -> str:
# Replace with your storage — DB row, S3 object, etc.
return pathlib.Path("/srv/launcher.json").read_text()
@app.get("/launcher.json")
def get_config(if_none_match: str | None = Header(None)):
body = current_config()
etag = '"' + hashlib.sha256(body.encode()).hexdigest()[:16] + '"'
if if_none_match == etag:
return Response(status_code=304)
return Response(
content=body,
media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-store"},
)
Minimum endpoint (Cloudflare Worker sample)
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const obj = await env.CONFIG_BUCKET.get('launcher.json');
if (!obj) return new Response('not found', { status: 404 });
const etag = `"${obj.etag}"`; // R2 gives you an etag for free
if (req.headers.get('if-none-match') === etag) {
return new Response(null, { status: 304 });
}
return new Response(obj.body, {
headers: {
'content-type': 'application/json',
etag,
'cache-control': 'no-store',
},
});
},
};
3. Configure in the Builder
Portal → Launcher tab → Remote config (Ent) section:
| Field | Notes |
|---|---|
| Endpoint | The full URL (https://…/launcher.json). Validated as a URL by the schema. |
| Poll interval | Seconds. Min 60, max 86400 (1 day). Default 300 (5 min). Pick the longest interval your customer-care SLA can tolerate — every poll counts against your endpoint's budget. |
| Use ETag | Default true. Set false only if your endpoint can't serve 304s. |
The corresponding config block looks like:
{
"remoteConfig": {
"enabled": true,
"endpoint": "https://config.acme.test/launcher.json",
"pollIntervalSeconds": 300,
"useETag": true
}
}
4. What runs at boot
The runtime's RemoteConfigPoller (src/runtime/features/remote-config.ts)
does this on every poll:
- Build the request — GET, with
If-None-Match: <stored etag>when ETag is enabled and we have one. - Up to 3 attempts on transient failure (network error / 408 / 429 / 5xx). Backoff between attempts: 200ms → 500ms → 1500ms (±50% jitter). 4xx other than 408/429 aren't retried — those are client-side problems that won't fix themselves in 1.5s.
- 304 → emit
pollNotModified, done. - 200 + schema-valid body → emit
pollSucceeded, replace the live config, persist body + etag to the device's last-known-good slot (localStorage on Android WebView). - 200 but schema fails → emit
pollParseErrorwith the Zod issue. The current config keeps running. - 5xx after all retries → emit
pollServerError. Current config keeps running. - Network error after all retries → emit
pollNetworkError. Current config keeps running.
Cold start: if a persisted LKG is present, the launcher renders it
immediately while the first network poll is in flight. A successful
poll replaces it; a pollServerError / pollNetworkError lets the
LKG stay live.
5. Diagnostics
- Diagnostic overlay (Pro
diagnosticOverlayflag) — long-press a corner of the launcher to see the most recent poll outcome + ETag. - Telemetry — every poll emits a
remoteConfigPolltelemetry event with{ status, etag, changed }if you have telemetry export enabled. Great for proving "fleet picked up the config change at HH:MM:SS." - Portal Devices panel —
last_seen_atis bumped on every config fetch (whether 200 or 304), so the panel doubles as a "this fleet is polling like it should" check.
6. Gotchas
- Don't change the tenant id mid-flight. The endpoint must serve a
config whose
tenantIdmatches the tenant the device booted into. A mismatch surfaces aspollParseError(since the field is required) and devices stay on their LKG. - Cache headers matter. Some CDNs strip ETag headers on
Cache-Control: public. Useno-store(recommended) or test your CDN's preserved-headers behavior. The launcher now tolerates a missing ETag header on a 200 (preserves the previously-stored ETag), but you lose bandwidth savings if every response is treated as new. - 5xx behavior on the device is benign — the launcher won't replace a working config with a broken response. You can take the endpoint offline for maintenance; devices keep running their last good config until the endpoint comes back.
7. Test fire
The Portal → Launcher → Remote config block has a "Test endpoint"
button. It sends a single GET to your endpoint (with no If-None-Match)
and reports HTTP status + whether the body parses through the schema.
Use this immediately after deploying a new endpoint to catch CORS / TLS
/ schema bugs before pushing to the fleet.