Change the look without shipping a build
Change how the copilot looks and opens without shipping a build.
Everything in Theming is a compile-time choice: you map your brand in Dart, and changing it means an App Store review. That is the right default, because your build should be reproducible. It is the wrong constraint the first time a client changes their brand colour on a Thursday.
So the four colour roles, the two radius steps and the whole opening surface can also be published from the console, where they become the default and your Dart becomes the floor.
What it changes
| Publishable | Where it lands |
|---|---|
surface, onSurface, accent, onAccent | Every block in the catalog, through the same derivation fromTokens runs. All four together or none: a partial palette cannot be checked for contrast, because the values it leaves out come from your Dart rather than from the console |
| Raised, sunk and high surface tints | The derived neighbors of surface, when the console publishes them explicitly |
| Block corner, control corner | RendelRadiusScale.card and .control (the wire also carries the smaller steps) |
| Font family names | The text and display faces, by name only, resolved against fonts your app already ships |
| Assistant name | The thread header |
| Launcher label | The word on RendelLauncher |
| Empty state line, opening suggestions | The empty thread |
| Availability | Hides the launcher and refuses open() |
Nothing else. Your persona, your knowledge, your action registry, your keys and your limits are never on this wire. See What is deliberately not published.
Draft and published are different channels
There is one config per app and two versions of it live at once.
- A test key receives the draft. Save in the console and your own app, running your test key, has it on its next launch.
- A live key receives the published version, and only after you press Publish.
Your app on a test key is the preview device. There is no second surface to keep in step, and the answer to "when does this reach my users?" is one word.
One thing to know about the draft: a test key is a publishable key, and anyone can extract one from a TestFlight or debug build exactly as they can a live one. The draft channel is a convenience, not a private one. Do not put an unannounced product name in launcherLabel and expect it to stay unannounced.
Every publish keeps the version it replaced, and restoring copies an old version back into the draft rather than straight to your users.
The SDK side
Nothing to wire. init() fetches the config, applies the cached one first so a second launch paints your look in its first frame, and falls back to exactly what you compiled in whenever anything at all goes wrong.
await Rendel.init(
appKey: key,
// Still the floor. Anything the console does not publish comes from here,
// and anything it publishes is layered over this rather than over the last
// payload that arrived.
theme: RendelTheme.fromTokens(
surface: brand.background,
onSurface: brand.text,
accent: brand.primary,
onAccent: brand.onPrimary,
),
config: const RendelConfig(
assistantName: 'Kicks Copilot',
launcherLabel: 'Ask',
),
);Keeping it between launches
The package takes no storage dependency, for the same reason it hand-rolls its SSE parser: a plugin in an SDK is a plugin in every host's build. So the cache is an interface with a process-lifetime default, and wiring a real one is three lines.
class PrefsConfigStore implements RendelConfigStore {
@override
Future<String?> read() async =>
(await SharedPreferences.getInstance()).getString('nc_config');
@override
Future<void> write(String value) async =>
(await SharedPreferences.getInstance()).setString('nc_config', value);
}
await Rendel.init(
appKey: key,
config: RendelConfig(configStore: PrefsConfigStore()),
);Without one the copilot still launches correctly every time. It simply paints your Dart config for the few hundred milliseconds before the first fetch resolves, on every cold start rather than only the first.
Turning it off
config: const RendelConfig(remoteConfig: false)Your build then looks exactly like your Dart says, forever. Use it if reproducibility matters more to you than a Thursday brand change.
Picking up a publish without a relaunch
Rendel.refreshConfig() re-reads it. Call it when your app returns to the foreground if you want that; the SDK does not hook the lifecycle itself, because an SDK that installs an app lifecycle observer in someone else's app is a surprise.
Every failure mode lands on your code config
This is the whole safety property, so it is worth being explicit. In each of these the copilot renders exactly what you compiled in:
- The app has never published a config
- The device is offline, or the request times out
- The API returns an error
- The payload is truncated, is not JSON, or is JSON of the wrong shape
- A value is out of range, such as a 900pt body size or a negative radius
- The cache on disk is corrupt
- The payload is from a newer server and contains keys this build has never heard of
The last one is a rule rather than an accident: unknown keys are ignored and never fatal, so an old binary in the field keeps working when the console learns a new control.
Contrast is reported, and the floor is enforced on the device
The console runs the SDK's own audit: the same lerps, the same bisections, the same thresholds auditContrast prints to your debug console at init. When you pick white on your brand orange it tells you it is 3.34:1, which control it affects, and what the app will draw instead.
It measures the value you typed, not the value the derivation rescues it to. Measuring the rescue made the check unfailable on the one pair it exists to catch.
It does not stop you saving. It used to, and that was the wrong mechanism for the right idea: the device never draws the colour you typed if it cannot be read — it deepens it and draws the result — so what was being refused was not an unreadable app but a swatch that will be adjusted. Which swatch a brand uses is the brand's call, and a screen that refused it mid-drag read as broken rather than as careful. The report stays, as a report; one click takes the adjusted value if you would rather your swatch matched the phone.
The floor itself has not moved, because it was never in the console:
- An illegible
onAccent,onSurface,onBubble,onComposeroronComposerFaintis deepened until it clears 4.5:1, in release builds, and your debug console says so. - A published theme whose derived roles still do not clear their floors on every surface they are drawn on is discarded whole, and the copilot renders the theme you compiled in. This is rare and it is silent: the save works and the app keeps its own colours. If a palette you published is not showing up, this is the first thing to check — deepen the gap between
surface,surfaceRaisedandsurfaceSunkand it resolves.
A customer's brand is applied through the accessibility floor, never around it. Where the floor is enforced is the device; the console only ever tells you what it will do.
What is deliberately not published
Publishable keys are public. rd_pk_live_... ships inside your binary and anyone can extract it, so everything on this wire is public by construction. The test is not "would a customer mind?" but "is this already visible to any user of the app?", and every field above is something the app draws on screen.
Kept out on purpose:
- Your persona. Name, tone, instructions and guardrails. The prompt is the most copyable thing in the product and it is never on the wire.
- Your knowledge sources, their URLs and their contents.
- Your action registry, its risk levels and its enable flags. What a copilot may do is answered per turn by the server, not published to the device.
- Plan, limits, usage and billing state.
- Anything naming your organization, your other apps, or any person.
- Any URL the SDK would fetch. Fonts are named, never linked: a remote font URL in a device-trusted payload is one redirect away from being a tracking pixel in someone else's app. Name a family your binary already carries; if it does not carry it, Flutter falls back and nothing breaks.
The availability switch is a convenience, not a security control. It stops the affordance being drawn. Suspending an app is a separate, server-side control that stops the API answering at all.