Concepts
Permalink to ConceptsThe native component model
Permalink to The native component modelA flow spec's screens[] array is 13 stock types (rendered by the target SDK's native UI components — SwiftUI for an iOS host or React Native components for an RN/Expo host — including welcome, singleSelect, paywall, and so on; see Reference → Node types) plus one escape hatch, custom, for anything those 13 don't cover. A custom screen names a native component the app itself registered:
{ "id": "bodyStats", "type": "custom", "component": "yourapp.bodyStatsQ", "props": { "min": 120 } }component is always "<namespace>.<name>" with exactly one dot. The namespace is a single identifier (letters, digits, underscore) — so when your appId is a real dotted bundle id (com.greeter.demo), that appId cannot be the namespace (it violates the one-dot rule). Derive a namespace from it instead (greeter from com.greeter.demo, then use greeter.bodyStatsQ). props is opaque JSON at this layer: its real shape is declared once, on the native side, and checked against what you send at publish time (next section). There is no way to author raw markup or a WebView slot in a v4 flow — a screen the stock vocabulary can't express becomes a real native component, not markup.
Custom components and the component manifest
Permalink to Custom components and the component manifestRegistering a component is two things, done together by one command:
weir components-sync --appRoot .- It reads
weir/components.json(one entry per registered component: name, target platform(s), a JSON Schema for its props, the app version it first shipped in) and sends it to the Weir API, which assigns the next manifest version for your app — an integer that only ever goes up, the same monotonic-version discipline flow publishing uses.
{
"version": 1,
"components": [{
"component": "fitness.hero",
"platforms": ["ios"],
"minAppVersion": "1.0.0",
"propsSchema": {
"type": "object",
"properties": { "headline": { "type": "string" } },
"required": ["headline"]
}
}]
}This is the hand-authored shape. Sync expands each platforms value into separate per-platform entries in the server-side manifest. The declaration contract is exact: top-level version is 1; every component entry has component, platforms, minAppVersion, and propsSchema. Do not add a description key — that is not a declaration field. For an Expo host, platforms must contain "reactNative" (not "ios"):
{
"version": 1,
"components": [{
"component": "cardclub.welcome",
"platforms": ["reactNative"],
"minAppVersion": "1.0.0",
"propsSchema": {
"type": "object",
"properties": { "headline": { "type": "string" } },
"required": ["headline"],
"additionalProperties": false
}
}]
}- It writes a generated registry source file for the target runtime (
WeirComponentRegistry.generated.swiftorWeirComponentRegistry.generated.ts) embedding that manifest version and a registry hash — a hash of every registered component's name and props schema. Commit the generated file and install it into the SDK registry before fetching.
At publish time, every custom screen's component is checked against your app's latest synced manifest: the component must be registered for every platform the screen targets, and its props must match that component's declared schema. A config naming an unregistered component, or sending props that don't match, is refused before it ever reaches /manifest.
Scope the flow to the same runtime. A custom screen with no screen or flow platform scope is treated as targeting every platform. Therefore, an Expo flow whose declarations say "platforms": ["reactNative"] must also retain this top-level field in the flow itself:
{ "platforms": ["reactNative"], "screens": [/* registered custom screens */] }Otherwise the conform gate correctly asks for iOS and Android registrations too, even though this app only delivers React Native. Keep the flow and component-manifest platforms aligned; do not work around the error by falsely adding another runtime to components.json.
The drift check. Registering a component in Swift code and forgetting to re-sync is the one mistake this exists to catch: the SDK compares its live registrations against the generated file once, at first render. A mismatch emits a health_registry_drift event, and in a debug build, stops with an assertion — re-run weir components-sync and rebuild.
Before build QA, run weir components-sync --appRoot . --check. This is a read-only preflight: it makes no API request and writes nothing, but fails when weir/components.json, the last synced manifest cache, or a generated registry source file has drifted. If it fails, run the normal sync, review and commit its output, then rebuild.
What your component receives at runtime (RFC-012 §6). A registered custom component gets its declared props (already validated against your propsSchema) plus one namespaced weir prop — the seam it uses to drive the flow, since a custom screen has no cta/next of its own:
interface WeirCustomScreenApi {
// Resolve this screen's next (or jump to an explicit screen id) and advance —
// the custom-screen equivalent of a stock component's CTA press.
submit(explicitTarget?: string): void;
// Record screen_skipped and advance via next — for a "maybe later" affordance.
skip(): void;
// Set a flow variable, e.g. a quiz-like screen's answer.
setVariable(id: string, value: JSONValue): void;
// Record a custom interaction event in the standard event envelope.
recordInteraction(type: string, fields?: Record<string, JSONValue>): void;
}Your component renders its own CTA and calls weir.submit() when done. submit("someScreenId") jumps (a Skip-to-last-screen affordance, for example). There is no flow-abort call from inside a custom screen — dismissal semantics belong to the host integration, not to individual screens.
Author, verify, and recover. Edit weir/components.json, run weir components-sync --appRoot . against the intended API, and commit/reference the generated registry file in each platform build. The command reports the assigned manifest version and each platform's registry hash. Rebuild and run the app: a debug registry-drift assertion or health_registry_drift means the native registrations and generated registry differ. Correct the declarations or native registrations, sync again, rebuild, and retry. To roll back an incompatible declaration, restore the last compatible components.json, sync it, and ship an app whose registrations match that generated registry.
Gating
Permalink to GatingEvery screen, and the flow as a whole, can declare a build-compatibility floor:
{ "gating": { "minAppVersion": "1.4.0", "platforms": ["ios"], "fallback": "serveOlderConfig" } }fallback says what a build that fails this screen's gate gets instead: "skip" drops the screen; "substitute" renders a named sibling screen (which must itself carry no gating — no chains); "serveOlderConfig" (the default) disqualifies the whole config version for that build, and the server walks back to the newest version the build actually can render. A build's own SDK version and registered-component registry hash travel with every config fetch, so an old build requesting a config with a component it never registered gets a servable older version instead of a config it cannot render. If no published version is servable, the SDK resolves through its verified cached and app-embedded fallback tiers instead of attempting to draw the rejected config.
Delivery model
Permalink to Delivery modelEdit a compatible flow →weir release→ relaunch the already-installed app → the verified config can render natively and its event can land inweir funnel.
That is the v4 loop running in the controlled private alpha. “Compatible” matters: copy, props, ordering, theme, and branches can change within the component vocabulary already in the installed build; adding a new native component still requires an app release. Its operational evidence is qualified: public health identifies the deployed commit and release, while the current dogfood evidence is not a public-beta or conversion result. The loop is built from a few pieces that each do one job:
- Publish —
weir_releasesendsconfig.json(the flow spec itself, serialized) plus anycustomAssets[]files it references to the delivery origin (services/api,POST /publish), which validates it (schema, then component-manifest checks for everycustomscreen) and updates that flow's/manifest/:flowIdroute. - Sign — the served manifest (config id, spec version, published version, the component manifest version it was validated against, and a sha256 per served file) is signed with an Ed25519 keypair. The private half lives only in deployment secrets (
WEIR_API_SIGNING_KEY_B64); the paired public key is baked into the SDK build and is safe to distribute — e.g. via Firebase Remote Config alongside the manifest URL. - Fetch, verify, gate, stage — the installed app checks for an update on launch and on foreground, sends its own app version, SDK version, platform, and registry fingerprint with the fetch, gets back whichever config version its own build can actually render (see Gating above), verifies the Ed25519 signature and every file's sha256, and stages the result — never swaps a live render mid-flow.
- Promote — the staged config is promoted to active only at the next flow presentation, never mid-flow. The render path resolves a verified promoted remote config first, then the app-supplied or app-embedded v4 baseline config. Source/version telemetry reports which tier actually rendered.
Every served file carries a lowercase SHA-256 of its exact bytes, covered by the same signature — the SDK re-hashes each fetched file before staging and rejects a mismatch.
Embedded baseline config
Permalink to Embedded baseline configEvery supported v4 host packages a real specVersion-4 config and its declared assets in the app binary. It is native-renderer input, not HTML and not a WebView artifact. SwiftUI accepts a config root containing either a single-flow config.json or flows/<flowId>.json; the RN/Expo config plugin embeds a directory containing config.json. Custom screens work in this tier because the native/RN component implementation ships in the same app build.
weir embed/weir init now produce a real specVersion-4 config.json — the native baseline that ships in the app and that weir_app_doctor checks. They also produce the internal browser-preview bundle (index.html plus manifest.json) used by weir walk/weir conform for headless QA; that bundle is an authoring harness, not the shipped deliverable. The native baseline (config.json) is what the SDK reads at runtime for its embedded fallback.
Kill switch
Permalink to Kill switchThe revert path for a bad remote flow must not depend on Weir's own infrastructure being healthy — so the kill switch lives on Firebase Remote Config, permanently, by design, not on anything Weir serves. A single flag (weir_onboarding_enabled, default false) plus weir_manifest_url / weir_manifest_public_key / weir_ingest_url / weir_ingest_token route the app's delivery and telemetry entirely through remote config the app already trusts. Flipping that flag off returns the app to its native onboarding (nativeFallback in weir/app.json) without needing the delivery origin to be reachable at all. The app-embedded v4 baseline config is the offline config tier if the host chooses to present Weir while remote delivery is unavailable; the app's separate native onboarding remains the outer fallback controlled by the kill switch.
Experiments
Permalink to ExperimentsThe Weir API's experiment layer (sticky assignment, sample-ratio-mismatch guardrails, peeking guardrails, a results API) is built and tested against a real Weir API, variant_assigned is a first-class event in the instrumentation contract above, and the variant→config serving seam means a published config genuinely carries per-arm content. The agent-facing lifecycle — weir_experiment_start/_status/_decide/_ship_winner — is registered (see Experiments for the full walkthrough). Two experiments are running on this pipeline now: exp_cob_commitment_first (CutOrBulk) and exp_psd_scan_demo_first (PSD), with 14-day horizons ending around 2026-08-17; decide a winner only via the pre-registered rule at horizon. EXP-001, the first experiment run on this pipeline, is closed — it validated the plumbing end to end but never reached enough real-user traffic to power a decision (see Experiments for its closure note). No conversion-lift claim is implied by an experiment being running. weir_experiment_results (a narrower, older results-only tool) stays unregistered in ALL_TOOLS, superseded by weir_experiment_status.
Instrumentation contract
Permalink to Instrumentation contractThe product's core guarantee, stated plainly: agents get full creative freedom on presentation, but the event stream underneath a flow is fixed and asserted, not something an agent can silently drop or rename. Concretely:
- The event vocabulary is closed — see Reference → Event vocabulary for the full list, and the public
weir-spec/EVENTS.mdfor the full spec.validateEventStreamchecks every emitted event parses against its exact shape and thatseqis monotonic starting at 0. weir_conformasserts the browser-preview artifact's emitted stream, for a scripted persona walk, matches what the flow spec's screens/branches/variables imply. That is authoring-time contract evidence; it does not substitute for native SDK/device tests.- Every event envelope carries
{ flowId, screenId?, variantId?, sessionId, userId?, ts, seq, payload, screenDwellMs? }. Device context (platform, app version, SDK version, locale, device class) rides once per flush batch, not once per event. - Native appends events to a file-backed offline queue in order and owns flush policy.
weir_funnel/weir_health/weir_eventsread the ingested result of that queue viaservices/api's/read/*routes. getResults()(the experiment results path) dedupes conversions byuser_id, falling back tosession_idonly for older clients with no host-injected user context — soassignedandconvertedare counted per user end to end, not per session.
This is why the toolkit doesn't need a visual pixel-diff or a compliance linter to make the "agents get creative freedom" pitch safe: the freedom is scoped to presentation, and the contract that isn't up for grabs is enforced mechanically on every build.