Raw Cards, end to end
How Dhruva chat cards ship from a JSON layout to a live, interactive, form-capable card on 5M+ devices — with no app release and no per-card engineering.
A raw card is a Dhruva chat card whose entire UI is a server-authored JSON tree of React-Native-style nodes — view, text, image, gradient, plus form inputs — rendered by one generic engine in the app (RawRenderNode). The backend owns the layout, the data, the copy, the actions, and even the card's after-life (what it becomes once answered or expired). The app owns exactly one thing: rendering the contract faithfully.
It is not a new widget type. A raw card is an sdui_card chat message with payload_format: 'raw_layout', riding the entire existing lifecycle — socket receive, SQLite persistence, history restore.
raw_layout envelopes are only for Dhruva chat cards. Any server-driven widget on other surfaces (loan marketplace, home, loans tab…) must be a real typed SDUI catalog widget (src/sdui, the pip_widgets style). This is a deliberate product/architecture decision, not a technical limitation.The four pieces
validateSdui, bind/field-ref resolvers, 10 publish-clean sample cards, and the authoring docs. One npm package consumed by all sides. Repo: Sixdis-Oolka/oolka-card-kit.cardId into a fully-bound tree: GraphQL fetch → CEL derivations → i18n → bind inlining.cardKey and proxies Card Service.src/screens/dhruva/dhruvaCorePhaseTwo/sdui-raw/ + DhruvaSduiCard. Renders the tree, runs local form state, dispatches actions, persists post-action transitions.Architecture
The core idea: the socket never carries a card. It carries a reference (a signal key + payload), and the app resolves it on demand. That is what makes cards updatable at any time — the layout lives on the server and every resolve returns the latest published version.
{ config: { signal, payload, layout: { type: 'raw_layout' } }, data: null }▼
wireEnvelope.isRawLayoutConfig → signalResolveService → POST {signal_url}/v1/runtime/signals/{signal}/resolve with { payload, clientCaps } + user JWT▼
payload.profileId from the token, evaluates the signal's published decision tree (upstream fetch → CEL facts → pure decision) → picks a cardKey▼
resolveBindings inlining → { card: { layout: { tree }, data, postTtl }, cardConfig }▼
Signal Service forwards Card Service's card verbatim and renames cardConfig → appConfig. The app therefore sees:
{
"signalKey": "PAYMENTS_IN_DHRUVA_PAY_BILL",
"card": {
"cardId": "BILL_TXN_SUCCESS",
"version": 7,
"layout": { "tree": { /* fully-bound raw tree */ } },
"data": { /* flat bind values */ },
"postTtl": { "action": "layout_change", "card": { "layout": { "tree": { /* replacement */ } } } }
},
"appConfig": { "ttlSeconds": 86400, "refreshRequired": true, "saveInHistory": true }
}
Where everything lives
| Piece | Repo / path | Stack |
|---|---|---|
| Contract | Sixdis-Oolka/oolka-card-kit (local: ~/apps/oolka-card-kit) | TS + Zod 3 (bundled), tsup, vitest — published to GitHub Packages |
| Renderer | Oolka-Mobile-App/src/screens/dhruva/dhruvaCorePhaseTwo/ — sdui-raw/, components/widgets/DhruvaSduiCard.tsx, services/signalResolveService.ts | React Native |
| Card engine | ~/apps/card-service | Bun ≥1.3, Hono 4, Drizzle + Postgres, CEL, zod 4 |
| Decision layer | ~/apps/signal-service | Kotlin 2.3, Spring Boot 4 WebFlux, Postgres (R2DBC), Redis |
| Authoring docs | card-kit docs/SPEC.md, docs/ACTIONS.md, docs/BACKEND_EXAMPLES.md, docs/VERSIONING.md | Ships inside the package |
Quick start
The fastest path from zero to a card rendering on a device.
1 · Learn the shape from a sample
card-kit ships 10 complete, publish-clean sample envelopes in samples/. Start from the one closest to what you're building:
| Sample | Shows off |
|---|---|
gradient-cta.json | The canonical showcase: header, status pill, impression, gradient CTA with all three action parts |
email-draft.json / email-sent.json | A rich production card and its answered/terminal twin |
close-overdue.json | Real overdue-loan settlement card |
review-autopays.json | Stacked logos, savings summary |
offer-carousel.json | Horizontal snap scroll — the "there is no repeater" pattern |
free-text-answer.json | Option rows + free text_input + post_action collapse |
dob-input.json | Segmented date input + submit_form |
consent-submit.json | Consent checkbox gating a CTA, post_action: dismiss |
select-question.json | select node + action plan + answered-state echo |
2 · Validate as you author
The authoring accept/reject loop is one function. A card is done when publish mode returns zero errors and zero warnings:
import { validateSdui } from '@sixdis-oolka/card-kit';
const result = validateSdui(myEnvelope, { mode: 'publish' });
// result.valid, result.errors, result.warnings — each { ruleId, path, message, severity }
3 · See it on a device (no backend needed)
- Install a dev build (
app.oolka.com.development) and log in with the dev bypass: phone 0000000003, OTP 123456. - Open the floating LOGS overlay → the pink Card tab.
- Enter a signal key (defaults to
PAYMENTS_IN_DHRUVA_PAY_BILL) and an optional payload — bare object,{"payload": {…}}, or a full frame copied from the Socket tab. - Have Dhruva chat open underneath, hit Send. The frame is injected through the real inbound socket path (
simulateIncomingWidget → onPillsWidget), so the card resolves against the active environment's Signal Service with your real JWT — identical to a backend-triggered card.
layout.type, the Card tab recognises it as a card envelope and runs validateSdui in publish mode on it, reporting up to 3 errors — a quick lint without leaving the phone. Your last signal/payload draft persists across app restarts (AsyncStorage @dev_raw_card_draft).4 · Ship it for real
A production card needs a published layout + card template in Card Service and a signal in Signal Service whose decision tree returns your cardId. See Authoring a card.
What we support today
| Capability | Status | Since |
|---|---|---|
Node types: view, text, image, gradient | ✅ Shipped | POC / 0.1 |
Form inputs: text_input (free text + segmented date), checkbox | ✅ Shipped | 0.7.0 |
scroll (horizontal snap carousel / vertical stack) | ✅ Shipped | 0.7.0 |
select (server-driven option list, tap = submit) | ✅ Shipped | 0.12–0.13 |
{bind} data refs, everywhere | ✅ Shipped | POC |
{field} form refs in dispatch + text.value echo | ✅ Shipped | 0.7.0 |
Interactions: send_message, accept_offer, submit_form | ✅ Shipped | 0.7.0 |
Interactions: share, download | ✅ Shipped (see old-client caveat) | 0.19.0 |
Action plans (interaction.plan → chat-service orchestrator) | ✅ Shipped | 0.8–0.15 |
post_action: layout_change / dismiss, persisted answered state | ✅ Shipped | 0.7.0 |
postTtl (expiry behaviour: replace / dismiss / freeze) | ✅ Shipped | prod July 2026 |
hidden (bindable conditional visibility) | ✅ Shipped | 0.16.0 |
date.isoField ISO twin for date inputs | ✅ Shipped | 0.18.0 |
post_action: card_change (name a card, server inlines it) | ⚠️ Contract only — Card Service rewrite not implemented | 0.12+ (authoring) |
clientCaps gating (don't serve unrenderable nodes to old clients) | ⚠️ App sends it; Signal Service drops it; routing unowned | — |
| Multi-select, repeaters, animation, custom fonts per card | ❌ Not supported (by design) | — |
Card anatomy card-kit
The unit of authoring is a CardEnvelope:
interface CardEnvelope {
schemaVersion: number; // authored against SDUI_SCHEMA_VERSION (currently 1)
layout: SduiNode; // the tree
data?: Record<string, unknown>; // display-ready values for {bind} refs
}
A minimal real card:
{
"schemaVersion": 1,
"layout": {
"type": "view",
"style": { "padding": 16, "borderRadius": 16, "backgroundColor": { "bind": "cardBg" } },
"action": [
{ "tracking": { "event_name": "WIDGET_RECEIVED", "extra_data": { "card": "demo" } } }
],
"children": [
{ "type": "text", "value": { "bind": "title" },
"style": { "fontSize": 16, "fontFamily": { "bind": "fontBold" }, "color": "#23272F" } },
{ "type": "gradient",
"colors": ["#1F4DFD", "#A84DDB", "#BF5CAB", "#E5457A"],
"start": { "x": 0, "y": 0 }, "end": { "x": 1, "y": 0 },
"locations": [0, 0.54808, 0.74519, 1],
"style": { "borderRadius": 12, "padding": 12, "alignItems": "center", "marginTop": 12 },
"action": [{
"tracking": { "event_name": "CTA_TAPPED" },
"interaction": { "interaction_type": "send_message",
"params": { "user_message": { "bind": "ctaUserMessage" } } }
}],
"children": [
{ "type": "text", "value": { "bind": "ctaLabel" },
"style": { "color": "#FFFFFF", "fontFamily": { "bind": "fontSemiBold" } } }
] }
]
},
"data": {
"cardBg": "#FFFFFF", "title": "Your bill is due",
"ctaLabel": "Pay now", "ctaUserMessage": "Pay my bill",
"fontBold": "DMSans-Bold", "fontSemiBold": "DMSans-SemiBold"
}
}
impression field
The card's impression is a tracking-only action entry on the root node, fired once by the app on render. Convention: event_name: "WIDGET_RECEIVED" (every sample does this, and a test asserts it). A legacy top-level impression key fails the publish gate.Node reference 8 types
KNOWN_NODE_TYPES = ['view','text','image','gradient','text_input','checkbox','select','scroll']
Props on every node
| Prop | Meaning |
|---|---|
style | Allowlisted RN style bag, applied verbatim (see Styling) |
pressedStyle | Merged over style while pressed |
disabledStyle | Merged over style while the node's gating fields are invalid — visual only, the node stays pressable so a tap surfaces validation errors |
hidden | Bindable<boolean>. Resolved truthy ⇒ node and its subtree not rendered. The only conditional-visibility mechanism; in practice always a {bind} on a computed flag. Old clients render the node — author it to degrade to empty copy. |
action | Array of action entries. A node is tappable iff some entry has navigation or interaction. |
view
The container. Only adds children. Becomes a Pressable when tappable.
text
Leaf. value: string | {bind} | {field} (required). The {field} form is the echo: it renders live from local form state (so a post_action replacement can show what the user just typed). A hardcoded lettered literal is an L3 publish error — bind all copy. Never give a leaf children.
image
Leaf. uri: string | {bind} (required, non-empty).
gradient
Container mirroring react-native-linear-gradient — the one thing a raw style can't express.
| Prop | Rules |
|---|---|
colors | Required, ≥ 2 colors, bindable |
start / end | {x, y}, each 0..1; default vertical |
locations | 0..1 each, non-decreasing, length must equal colors.length |
The standard Oolka CTA gradient: colors ["#1F4DFD","#A84DDB","#BF5CAB","#E5457A"], locations [0, 0.54808, 0.74519, 1], horizontal.
text_input
Leaf form field. Its value lives in the card's local form store under field.
| Prop | Rules |
|---|---|
field | Required. Flat camelCase id (/^[A-Za-z_$][\w$]*$/), unique per layout root |
inputType | 'text' (default) or 'date' — date renders a segmented DD/MM/YYYY control; composed value is the "DD/MM/YYYY" string |
placeholder, placeholderTextColor, selectionColor | Bindable; placeholder copy must be bound (L3) |
keyboardType | default | email-address | numeric | phone-pad | number-pad (ignored for date) |
maxLength, multiline, defaultValue | Standard; defaultValue seeds the store once |
validation | { regex?, errorText?, required? } — regex is full-matched against the trimmed value; fields referenced by a dispatching action are required by default (required: false opts out) |
focusedStyle / errorStyle / errorTextStyle | State styles; on date, focusedStyle applies to the active segment |
sensitive | Occlude in session replay (UXCam) — DOB, email, PAN… |
date | { isoField?, placeholders? {day,month,year}, segmentStyle?, yearSegmentStyle? } |
date.isoField — the ISO twin (0.18.0)
Declare a second field id and the renderer writes an ISO YYYY-MM-DD twin on every change ('' until all segments are complete). Point plan.inputs at the isoField; keep chat bubbles and echoes on field (the display string).Enter-to-submit: a text_input may carry its own action, dispatched on keyboard submit (never tap — tap is focus).
checkbox
Leaf. Renderer draws the box + ✓ glyph; labels are sibling text nodes. Props: field (required), defaultChecked, checkedStyle, checkColor, validation (regex is ignored — only required + errorText apply), errorStyle, errorTextStyle, sensitive.
select
Leaf, single-choice list, one row per element of options — the whole reason it exists: a server-added option renders without re-authoring the card (unlike an unrolled option1Label/option2Label card that freezes the count).
{
"type": "select",
"field": "employmentType",
"labelField": "employmentLabel",
"options": { "bind": "employmentOptions" },
"numbered": true,
"rowStyle": { "padding": 12 },
"separatorStyle": { "borderTopWidth": 1, "borderTopColor": "#E6E8F0" },
"selectedStyle": { "backgroundColor": "#EEF2FF" },
"action": [{
"tracking": { "event_name": "OPTION_PICKED" },
"interaction": {
"interaction_type": "send_message",
"params": { "user_message": { "field": "employmentLabel" } },
"plan": { "planId": "loan.submit.employment", "actionId": "employment-pick",
"intent": "SUBMIT_INPUT",
"inputs": [{ "key": "employment_type", "value": { "field": "employmentType" } }] }
},
"post_action": { "action": "layout_change", "layout": { /* answered view */ } }
}]
}
- Tap IS the submit: the renderer writes the option's
value→fieldand itslabel→labelField, then dispatches the node's own action. One action serves every option; read the choice back with{field}refs. - Decoration:
rowStyle,separatorStyle(every row except the first),numbered+badgeStyle/badgeLabelStyle,selectedStyle,labelStyle,selectedLabelStyle. validation.regexis inert (closed server-owned list); nosensitive, no pre-selection, no multi-select — all deliberate.
scroll
Multi-card container. direction: 'horizontal' (default — a carousel) or 'vertical' (renders a static stacked column; chat is already a list). snap snaps to child boundaries (interval = first child width + gap; horizontal only). Children are typically one fixed-width view per card. There is no repeater — unroll bind keys per item (offer1Amount, offer2Amount…).
Styling
Every style value is bindable. The bag is a strict allowlist (rule S1) — an unknown key or bad value is a publish error (client mode drops it instead of failing):
| Group | Keys |
|---|---|
| Flexbox | flex, flexGrow/flexShrink (≥0), flexBasis, flexDirection, flexWrap, alignItems, alignSelf, justifyContent, gap/rowGap/columnGap (≥0) |
| Box | margin+6 variants (negatives OK), padding+6 (≥0), width/height, min/maxWidth/Height, aspectRatio (>0) |
| Border | borderWidth+sides (≥0), borderColor+sides, borderRadius+corners (≥0, no percents), borderStyle |
| Surface | backgroundColor, opacity (0..1), overflow, elevation, shadowColor/Opacity/Radius/Offset |
| Position | position, top/right/bottom/left, zIndex |
| Text | color, fontSize (>0), fontWeight (strings only: "normal", "bold", "100"–"900"), fontFamily, fontStyle, lineHeight, letterSpacing, textAlign, textDecorationLine, textTransform |
| Image | resizeMode, tintColor |
- Dimensions: a number (dp) or
"N%". - Colors:
#RGB/#RGBA/#RRGGBB/#RRGGBBAA,rgb()/rgba(), or"transparent". Named CSS colors are rejected on purpose (deterministic agent output). - Deliberately absent:
transform(percentage-string transforms crash the Android native bridge),display, all web-only props.
Fonts — a convention, not code
The renderer has zero font logic. Layouts bind fontFamily to data keys — fontBold / fontSemiBold / fontMedium / fontRegular — and the backend supplies the values, currently DMSans-Bold, DMSans-SemiBold, DMSans-Medium, DMSans-Regular (also registered: ExtraBold, Black). Typography is therefore a backend data change.
react-native-asset links files at assets/fonts/ root only, not subdirectories).Layout gotchas from production
- Collapse-to-zero text slots must collapse via a bound
height—fontSize: 0leaves a line box and flexgapstill applies (the off-center Retry bug). - Fixed CTAs: bind all of a ghost line's
height/fontSize/lineHeighttogether.
Data binding
Two structurally distinct ref types. Both are strict single-purpose objects — extra keys make them invalid.
{ "bind": "key" } — server data
- Legal in any value position: text values, uris, colors, every style value,
hidden, action params, plan input values, placeholders, error copy… - Resolved by
resolveBindings(value, data)— the same function on the server (bind inlining at resolve) and the client (@sixdis-oolka/card-kit/runtime), so a bound literal and a raw ref render identically. datais a flat camelCase namespace (payload ∪ extracted facts ∪ CEL outputs ∪ i18n keys, merged server-side). Dotted paths still resolve for legacy payloads but are an L2 warning.- A missing key resolves to
undefinedand renders empty — never crashes. - Naming convention:
ctaLabel,ctaUserMessage,lenderLogoUrl,themeAccentSoft,analyticsSource. Data must arrive display-ready (formatted amounts, final copy). deriveBindSlots(layout)collects every bind path (Card Service persists this asbind_slotsand ships only those keys);checkBindCoveragereports missing/unused.
{ "field": "id" } — local form state
- Declared by
text_input.field,checkbox.field,select.field, plus the secondary idsselect.labelFieldandtext_input.date.isoField. Ids are unique per layout root; apost_actionreplacement is its own scope for declaring but inherits the parent's fields for resolving (so it can echo the submitted value). - Legal positions:
text.value(render-time echo),interaction.params.*,interaction.plan.inputs[].value,navigation.params.*, entry-leveluser_message. - Resolved at dispatch time from the card's form store (
resolveFieldRefs);resolveBindingsdeliberately passes them through, so they survive server-side inlining and reach the device intact.
tracking
A {field} ref anywhere in a tracking block is an A1 error — typed input is PII and must not reach analytics. The dispatcher enforces it too: tracking is reattached unresolved.Actions
action is always an array of entries. Every entry can carry up to four parts; on tap, all tap entries dispatch together.
interface ActionInput {
tracking?: { event_name: string; extra_data?: Record<string, unknown>; include_channels?: string[] };
navigation?: { url?: Bindable<string>; screen?: string; params?: Record<string, unknown> };
interaction?: { interaction_type: string; widget_id?: string;
params?: Record<string, unknown>; fields?: string[]; plan?: ActionPlan };
post_action?: { action: 'layout_change' | 'card_change' | 'dismiss'; layout?: SduiNode; cardId?: string };
user_message?: string | {bind} | {field}; // legacy shorthand → normalized into send_message
}
- A node is tappable iff some entry has
navigationorinteraction. A tracking-only entry on the root is the impression, fired once on render. post_actionalone is not a valid entry, and pure tap-logging is not expressible (known limitation — a tracking-only entry doesn't make a node tappable).
tracking
The app injects extra_data.source ('socket' for live delivery, 'hydrate' for restored cards) and maps include_channels names through the app's EVENT_CHANNELS: ALL, FACEBOOK, CLEVERTAP, MIXPANEL, SINGULAR. Empty or unmapped ⇒ defaults to MIXPANEL.
"FIREBASE" is a silent no-op
The kit docs' example ["MIXPANEL","FIREBASE"] is misleading: the app has no FIREBASE event channel. Unmapped names are dropped. Author MIXPANEL/CLEVERTAP.navigation
Requires url or screen. App behaviour, in order: screen → navigation.navigate(screen, params); an http(s):// url → internal WebView; any other scheme → Linking.openURL (params, if present, are appended as ?params=<encoded JSON> — legacy convention).
oolka://TransactionDetails/:orderId?billId=…&source=…, oolka://paymentIntermediary/:billId?utm_source=dhruva_chat&utm_medium=in_app. Paths are case-sensitive. Boolean query params: omit when false ("false" is a truthy string).interaction
KNOWN_INTERACTION_TYPES = ['send_message','accept_offer','submit_form','share','download']. The type is an open snake_case string — unknown types publish with an A1 warning and fall through to the socket path on the device.
| Type | Params contract | What the app does |
|---|---|---|
send_message | params.user_message required (literal, {bind} or {field}) | Sends a user chat message via the card send path (MESSAGE_SOURCE.Sdui_Card); plan spreads into metadata |
accept_offer | Full widget body in params | Socket widget message (below) |
submit_form | params and/or fields (gate-only ids); warned if neither | Socket widget message with resolved params |
share | params.message and/or params.file_url; optional channel: 'whatsapp'|'email', title, filename, phone. params.url is rejected — use file_url | Native share (channel → direct app, fallback system sheet; file_url fetched + attached) |
download | params.file_url required; optional filename | Android DownloadManager / iOS fetch + share sheet |
The socket fallback (submit_form, accept_offer, unknown types):
{
"widgetType": "sdui_card",
"widgetId": "…",
"widgetData": [{ "signal": "…", "identifier": "…",
"action": "submit_form", "payload": { /* resolved interaction.params */ },
"plan": { /* if present */ } }],
"source": "Sdui_Card"
}
share/download routes them to the socket fallback — a share tap on an old build sends a stray widget message. clientCaps has no interactionTypes half, so this can't be gated yet. Weigh rollout timing when authoring cards with new types.interaction.plan — driving the chat orchestrator
The plan block is camelCase on purpose: it's chat-service vocabulary, not SDUI. The app resolves {field} refs in inputs then spreads the block flat into the outgoing message's metadata — planId/actionId/inputs sit parallel to source/signalKey/messageId/cardId. No wrapper object — metadata.planId is the orchestrator trigger; wrapping it disables every plan silently.
| Key | Rules |
|---|---|
planId | Required. Dotted lowercase, ≥2 segments (loan.submit.employment). Registry: GET /chat/action/plans |
actionId | Required. Half of the idempotency key ${instanceId}:${actionId} |
inputs | [{ key, value }] — authored directly in the wire shape; keys unique; values are scalars, {bind} or {field} |
intent | SCREAMING_SNAKE (SUBMIT_INPUT, FREE_TEXT) — author it; the orchestrator defaults to SUBMIT_INPUT and ignores the plan definition's intent |
trigger / source | Optional; TAP/SUBMIT/TOGGLE; source = signal name for the plan's emit |
post_action — the card's answered state
An in-place transition applied after the entry dispatches successfully (the validation gate runs first). One level only — a replacement layout must not carry its own post_action (A1 error). When several entries dispatch together, the app applies the first entry's transition.
| Verb | Meaning |
|---|---|
layout_change | Requires layout — swap the card for the bundled replacement tree. The replacement inherits the submitting layout's fields, so it can echo answers via {field} in text.value. |
dismiss | Hide the card. |
card_change | Authoring-only. Requires cardId, forbids layout: name another card and Card Service resolves it (its own query/mappings/i18n) and rewrites the entry to layout_change before serving — a device never sees card_change. A served layout_change may carry cardId alongside layout as provenance. ⚠️ The rewrite is not implemented in Card Service yet — the contract exists in card-kit only. |
Unknown verbs are a client no-op (forward-compatible). Don't confuse post_action (what happens after the user acts) with postTtl (what happens when the card expires) — see Caching & TTL.
Forms & inputs
Everything form-shaped lives in a per-card-instance store on the device — the server never sees keystrokes, only what an action explicitly dispatches.
The form store
- Registry key:
`${messageId}::${identifier}`, viagetOrCreateFormStore(sdui-raw/form-store.ts). Max 50 stores, oldest evicted. - Cleared when the chat unmounts — deliberately not on focus, so typed values survive a payment round trip but not a session.
- Inputs subscribe per-field via
useSyncExternalStore(form-context.tsx) — a keystroke re-renders only that input.
Validation semantics
| Kind | Rule |
|---|---|
| All | required defaults to true when the field gates a dispatch; strings are trimmed; error copy = validation.errorText |
text | regex full-matched against the trimmed value; a regex that fails to compile passes everything (never bricks a card — but it's a publish error anyway) |
date | Must be a real calendar date: DD/MM/YYYY regex + month 1–12 + day within the month's length |
checkbox | required ⇒ must be checked; regex ignored |
select | required ⇒ an option chosen; regex inert |
Timing: register on mount → validate on blur → eager re-validate while in error → always re-validate at dispatch.
Dispatch gating
On tap, the app computes the entry set's gating fields: every {field} ref in user_message, interaction.params, plan.inputs and navigation.params, unioned with the explicit interaction.fields list. If any is invalid, nothing dispatches — no tracking, no navigation, no post_action. disabledStyle reflects this state visually but keeps the node pressable so the tap surfaces the errors.
The date input
Three real TextInput segments (DD / MM / YYYY): tap any box to edit (select-on-focus overwrites), auto-advance on fill, backspace-on-empty retreats, blur debounced 100 ms so segment hops don't validate mid-entry. sensitive: true occludes all three segments.
Echoing answers
A {field} in text.value renders live from the store. When a post_action is applied, render-position echoes are frozen to literals before persisting (they survive new sessions); action-position refs in the replacement stay live on purpose (the replacement's own inputs are unset at swap — freezing them would break its CTAs).
sensitive value rendered as plain text is not occluded in session replay. Don't author echoes of sensitive fields.Validation validateSdui
validateSdui(input, {
mode: 'publish' | 'client', // default 'publish'
target: 'envelope' | 'layout' | 'auto',
maxDepth: 32, maxNodes: 500,
rules: { L2: 'error' }, // per-rule severity override, or 'off'
externalFields: ['hostField'], // ids a host injects (card_change targets)
})
// → { valid, errors, warnings, normalized } — issues are { ruleId, path, message, severity }
The six rules
| Rule | Scope | Highlights |
|---|---|---|
| L1 structure | Shape, depth/node caps, per-node sanity | Leaf with children; gradient locations mismatches; pressedStyle without action; regex that doesn't compile; date props on non-date inputs; duplicate field ids per scope |
| L2 binds | Bind hygiene | Dotted paths (suggests flat camelCase); bind slots missing from data. Card Service raises L2 to error |
| L3 hardcoded copy | Publish only, always error | Any literal containing a letter in any script in user-visible positions (text.value, placeholders, errorText, option labels, user_message) must be a {bind}. Letter-free glyphs (₹, ✓, digits) pass |
| L4 schema version | Envelope | schemaVersion integer, 1 ≤ v ≤ SDUI_SCHEMA_VERSION; client mode defaults missing/malformed to 1 |
| S1 style | Style allowlist | Unknown key / bad value / percent borderRadius / numeric fontWeight / named color |
| A1 actions | Action shape | Errors: {field} inside tracking (PII); duplicate plan.actionId per layout; post_action chaining; unknown field id. Warnings: unknown interaction type; submit_form with no params/fields; impressions on non-root nodes |
Publish vs client mode
| publish (authoring/CI/Card Service) | client (device, dev-only) | |
|---|---|---|
| Objects | .strict() — unknown props rejected | Lenient — unknown props tolerated |
| Unknown node types | Rejected | Warning; rendered as a plain view |
| Bad style values | Error | Dropped silently |
| Field scoping, L3, L2 | Enforced | Not run at all |
Legacy single-object action | Rejected | Coerced to an array |
The tree walk (walkNodes) follows children and every post_action.layout — replacement trees render too, so every rule sees them. Replacement roots restart depth and count as layout roots.
On-device, the app runs client-mode validation on every resolved tree — dev builds only (the require('@sixdis-oolka/card-kit') sits inside an if (__DEV__) block so Metro folds the validator + zod out of release bundles). Watch Metro logs for 🪄 SduiCard card-kit … lines.
Versioning & clientCaps
Two version numbers, one capability handshake:
SDUI_SCHEMA_VERSION= 1 — the contract generation an envelope is authored against. All evolution so far has been additive (new optional props, node types, style keys), so it has never bumped. Never rename, remove, retype, or make optional→required within a schema version; that's what a major bump is for.- Package semver (card-kit is at 0.21.0) — patch = fixes/docs/samples, minor = additive schema change, major = schema version bump.
clientCaps — what the app advertises
// signalResolveService.ts — sent in every resolve body
const SUPPORTED_NODE_TYPES: Record<KnownNodeType, true> = {
view: true, text: true, image: true, gradient: true,
text_input: true, checkbox: true, select: true, scroll: true,
};
const CLIENT_CAPS = {
maxSchemaVersion: SDUI_SCHEMA_VERSION,
nodeTypes: Object.keys(SUPPORTED_NODE_TYPES),
};
Why the Record instead of spreading the kit's KNOWN_NODE_TYPES: a kit pin bump must not auto-claim a node type the renderer hasn't implemented — the record is a both-direction completeness lock that fails tsc until someone consciously adds the renderer.
- ✅ The app sends
clientCapsin every resolve body. - ❌ Signal Service silently drops it — its
ResolveRequestDTO has noclientCapsfield and Jackson ignores unknown properties. - ⚠️ Card Service accepts it and trace-warns on unrenderable layouts — but only on the unmerged
feat/interactive-cards-card-kit-0-7-0branch;mainpins card-kit 0.6.0 and knows nothing of caps. - ❌ Actual caps routing (choosing a card the client can render) is Signal Service's job per design — unowned, zero code.
Wire & resolve flow app
1 · The socket reference
{
"type": "widget-suggestions",
"messageId": "…",
"config": {
"signal": "PAYMENTS_IN_DHRUVA_PAY_BILL",
"payload": { "accountId": "…", "profileId": "…", "billId": "…" },
"layout": { "type": "raw_layout" }
},
"data": null,
"fetchedAt": "…"
}
wireEnvelope.ts: isRawLayoutConfig = config.layout?.type === 'raw_layout' || config.payload_format === 'raw_layout'. adaptWidgetWireMessage derives identifier (prefers payload.accountId, then profileId, then joined primitives) and forces refresh_required: true for raw references with data == null. Live deliveries are marked (markDeliveredLive) so tracking can distinguish socket vs hydrate.
2 · The resolve call
services/signalResolveService.ts (inside the dhruva folder). Configured on chat entry with the signal_url from the chat-config API (not env constants) + the user's JWT:
POST {signal_url}/v1/runtime/signals/{signal}/resolve
Authorization: Bearer <user JWT>
Content-Type: application/json
{ "payload": { …reference payload… }, "clientCaps": { "maxSchemaVersion": 1, "nodeTypes": [ … ] } }
Status mapping: 404/410 → gone (tombstone, sticks for the session) · other non-OK or missing tree → error (retry card, evicted from cache) · success → envelope.
3 · Envelope extraction
extractResolvedEnvelope tolerates card.layout.tree (current wire), card.layout-as-tree, or a bare tree; maps appConfig → ttl / refresh_required / save_in_history; rebuilds postTtl into { action, layout, cardId, cardVersion }. In dev, the tree runs through client-mode validateSdui.
4 · When does a card (re)fetch? — shouldHydrate
unavailable→ never.- Raw reference with
data == null→ always (even first live delivery — raw cards are reference-only). refresh_required !== true→ no.- Delivered live this session → no (it just arrived).
- No data → yes. No/zero TTL → no.
- Otherwise: refetch only while
now − messageCreatedAt < ttlseconds.
5 · Render
DhruvaSduiCard picks the tree via selectRawTree, wraps it in a RawFormProvider, and hands it to the recursive RawRenderNode with onAction = rawOnAction. Card states: ready / hydrating / unavailable / invalid / failed — with a skeleton (payment variant for raw cards), a tap-to-retry card, and a "Showing last available details" stale banner.
Caching & TTL
The session resolve cache
- Key =
signal + stable(payload)— the whole payload, order-insensitive. Every widget sharing a bill shares one resolve. - The cache stores the promise, so concurrent mounts dedupe into one network call.
errorresults evict themselves (retry refetches);okandgonestick for the session.- Cleared on every chat focus (
clearSignalResolveSession) — staleness is bounded to one chat session; backend layout deploys reach clients on next chat entry. evictSignalResolve(signal, payload)drops one key — used after a post_action so a remount doesn't resurrect the pre-answered card.bumpSignalResolveRefresh()pings mounted cards to re-resolve in place (used on payment return).
TTL and postTtl — the card's expiry
ttlSeconds counts from messageCreatedAt. Within the window, the card re-resolves on each chat entry. Past it, the card freezes (no network) and postTtl decides what shows:
postTtl.action | Past-TTL behaviour |
|---|---|
layout_change | Render the bundled replacement tree (persisted, works offline) |
dismiss | Hide the card |
noaction / unknown | Freeze the main snapshot |
Tree precedence (selectRawTree): applied post_action (user answered) > postTtl (expired) > main.
Post-actions & persistence app
How an answered card stays answered:
rawOnActiongates on form validity, resolves{field}refs per entry, dispatches tracking → navigation → interaction, and takes the first entry'spost_action.applyPostActionToRawEnvelopebuilds the new envelope: forlayout_changethe replacement is promoted ontolayout(with render-position echoes frozen to literals) andcardId/cardVersion/postTtlcleared;dismissstores a marker; unknown verbs no-op.- Persisted to SQLite via
updateSduiCardEnvelope(messageId, identifier, updated)— it patches the matchingwidgetDataentry inside the chat message's metadata. - The in-memory message list is patched too (the container's
handleCardEnvelopeUpdate) — the FlatList windows at 7, so a scrolled-away card would otherwise revert. - The resolve cache entry is evicted so a fresh mount doesn't fetch the pre-answered card over the persisted state.
History restore (historyService.js) re-detects raw references by config.layout.type === 'raw_layout' (they once mis-routed to the domain /widget/rehydrate path — check the config shape, not the presence of data).
send_message restore from history in their unanswered state — the server must serve the answered variant after a submit for history to be correct. submit_form flows (where the backend records the answer) are fine.Payment round trip
Cards that launch payment flows come back to a card that already reflects the payment:
- Card deeplinks carry
utm_source=dhruva_chat(the app also stamps it client-side for Pay-in-EMI navs viawithDhruvaOrigin). - Payment screens record the outcome in the session store
src/utils/paymentReturnInfo.ts—action: paid | failed | pending | back | pay_later, plus flow (payment_intermediaryorpay_in_emi) and bill context. - On Dhruva refocus, the container consumes the record: fires the return analytics event (
dhruva_payment_intermediary_return/dhruva_pay_in_emi_return), sends a background chat message whosemetadata.sourceis a mapped screen key (e.g.loan_txn_success_screen,back_button_amount_page) so the bot responds contextually (typing dots shown, no local user bubble), and callsbumpSignalResolveRefresh(). - Session caches were just cleared on focus, so every mounted raw card re-resolves over the network (payment-variant skeleton) and swaps in place — e.g. "Pay now" → "Payment successful".
Analytics & impressions
- Impression = root tracking-only entries, fired exactly once per rendered tree (keyed
__raw_${source}_${cardId}_${index}). A post_action/postTtl replacement root is its own root — it can carry its own impression. - Every event gets
extra_data.source:'socket'(delivered live) or'hydrate'(restored/resolved later). - Channels:
include_channelsmapped throughEVENT_CHANNELS; default MIXPANEL. - Card-initiated chat messages emit
CARD_MESSAGE_SENT(Source: 'sdui_card'); message metadata carriessignalKey,messageId(the originating card message),cardIdand any plan keys — the backend correlation contract. - A non-raw sdui_card that maps to zero widgets emits
SDUI_CARD_RENDER_FALLBACK.
Dev testing
| Tool | What it does |
|---|---|
| LOGS overlay → Card tab | Inject a reference frame through the real socket path against the active env (see Quick start). Gated on IS_DEV (build env, works in release-mode dev builds). Warns if Dhruva chat isn't mounted. |
| Envelope paste-lint | Paste a full envelope into the Card tab → publish-mode validateSdui report on-device. |
| Dev resolve validation | Every resolved tree runs client-mode validateSdui; issues log as 🪄 SduiCard card-kit …. |
| card-kit samples | samples/*.json — importable, publish-clean references. |
| Card Service preview | POST /v1/cards/:cardId/preview replays the full pipeline against the stored fixtures per locale — no device needed. |
tools/sdui-preview (paste JSON → phone-frame render via react-native-web) was removed with the POC cleanup. Only a stale gitignored node_modules/ remains. Rebuilding it against the current contract would be a great contribution.Signal Service backend
Kotlin 2.3 / Spring Boot 4 WebFlux / Postgres (R2DBC + Flyway) / Redis. Two apps: runtime-api :8080 (the app-facing resolve) and authoring-api :8081.
What it owns
- The signal registry:
signals,signal_versions,signal_pins,signal_samples,signal_configs, audit log. Card ids are stored as opaquecard_keystrings — Signal never shares a DB with Card Service. - The decision: a published, versioned decision tree per signal, evaluated against a FactBag (upstream fetch → in-process CEL fact extraction → context facts → pure decision engine) → yields a
cardKey. - The proxy:
ResolveOrchestratorcalls Card ServicePOST /v1/cards/{cardId}/resolvewithversion="active"(runtime never pins a version) and forwardscardverbatim, renamingcardConfig→appConfig.
Resolve endpoint
POST /v1/runtime/signals/{signalKey}/resolve · Bearer user JWT (HS256)
{ "env": "PROD", "payload": { … }, "userContext": { … }, "locale": null, "includeTrace": false }
→ { "signalKey", "signalVersion", "cardKey", "card": { … } | null,
"appConfig": { … } | null, "cardError": { "status", "body" } | null,
"reason": "MATCHED", "degraded": false, "trace": [ … ] }
- Totality: failures degrade to the tree's default leaf with
degraded: truerather than erroring; only an unknown/not-live signal 404s. On Card Service failure the client still getscardKey+cardError. - Profile scoping: the JWT claim
idoverwrites any client-suppliedpayload.profileId(config flag, default on) — a caller can only resolve for themselves. - Request HTTP headers are lowercased into the request so decision trees can read
request.header.<name>. - Dev escape hatch: a blank
JWT_SECRETdisables auth entirely (WARN logged).
ResolveRequest has no clientCaps field and Jackson is configured to ignore unknown properties — the caps the app sends are dropped on the floor. Adding the field + caps-aware routing is the single highest-leverage backend contribution right now.Card Service backend
Bun ≥ 1.3 (no build step), Hono 4 via Bun.serve, Drizzle + Postgres (card_db), CEL (@marcbachmann/cel-js), zod 4, pino, DogStatsD. Port 8140. Its only runtime upstream is the GraphQL gateway (GQL_URL).
Entities
- Layout (
lid+ versions): the SDUItree, derivedbind_slots, optionalclient_caps {minSchemaVersion},figma_ref. - Card (
cardId+ versions): a frozen pin to a layout version + query version,domain_mapping(CEL extraction from the GraphQL response),compute_mapping(chained CEL steps), per-localei18n(keys ARE bind names,{param}interpolation), derivedrequired_payload, fixtures (sample_payload+sample_response, required to publish),config(=cardConfig:ttlSeconds,refreshRequired,saveInHistory,postTtlCardId).
Lifecycle
Draft → publish → active, for both layouts and cards. One draft per identity; version numbers assigned only in the publish transaction; ACTIVE = pointed at by active_version_id, ARCHIVED = published-but-unpointed (both derived, never stored). Publish runs the validation gate inside the identity-locked transaction. Rollback repoints the active pointer. Everything is audit-logged by DB triggers with X-Actor attribution.
The gates
- Layout publish = card-kit's
validateSdui(tree, { mode: 'publish', target: 'layout', rules: { L2: 'error' } })— Card Service authors zero SDUI rules of its own ("never re-vendor SDUI validation"), plus L4 against the layout's declaredclientCaps.minSchemaVersion. - Card publish = C1–C9: layoutRef must be published (C1); bind coverage — layout slots ⊆ produced data (C2); no name collisions between facts/outs/i18n keys (C3); typed CEL static checks (C4); every i18n
{param}resolvable (C5); fixtures must run the whole pipeline cleanly (C6); GraphQL query parses, single named operation (C7); extraction CEL checked against a schema inferred fromsampleResponse(C8);cardConfigsanity incl.postTtlCardIdresolution, no self-reference (C9).
API surface
| Plane | Endpoints |
|---|---|
| Serving (any key) | POST /v1/cards/:cardId/resolve · GET /v1/cards, /catalog, /:cardId/bundle, /exists, /versions[/:version] · GET /v1/layouts/:lid/exists, /versions, /:version |
| Authoring (authoring key) | For layouts and cards: POST / (create draft), PUT /:id/draft, POST /:id/publish, /rollback, /validate, DELETE /:id/draft · cards add POST /:cardId/preview · plus POST /v1/gql/execute |
Auth: no JWT — internal-only shared secret header X-Card-Service-Key (constant-time compare; missing/wrong/unknown-path all return identical 401s). The user JWT never reaches Card Service; Signal Service authenticates with the shared secret. ⚠️ The authoring secret currently falls back to the serving secret when unset — any caller that can resolve can also publish.
origin/main pins card-kit 0.6.0 and predates forms/caps. The node_types derivation (walkNodes over every save, migration 0008, backfill script, CardBundle.nodeTypes) and clientCaps acceptance + trace-warn live on the unmerged feat/interactive-cards-card-kit-0-7-0 branch. Merging it (and bumping the kit pin) is a prerequisite for any caps work.Resolve pipeline card-service
POST /v1/cards/:cardId/resolve body: { payload, locale, version: 'active', bindData: true, trace: false }. The pipeline is total — after the payload check, nothing throws; a failed step traces and leaves its bind absent (which renders empty on device).
- Payload check — each derived
requiredPayloadentry: missing-required or type mismatch ⇒422 PAYLOAD_INVALID. - Fetch — one GraphQL POST with variables picked from the payload. An empty query is the "no upstream data" sentinel. Memoized per resolve (exact or proven-subset query + same payload) so a postTtl target sharing the query costs one fetch.
- Extract —
facts = payload ∪ CEL(domain_mapping)over the raw response, typed with defaults. - Compute — chained CEL steps (
compute_mapping) over the typed facts. - i18n — locale entries interpolated; keys are bind names.
- Pick — ship only the layout's
bind_slots; intermediates never leave the server. Missing slots trace + metric. - Bind inlining —
resolveBindings(tree, data)(the same card-kit function the client uses), so trees arrive fully bound;dataships alongside.
- Clock-free:
payload.todayis caller-supplied for replayability (backfilled once if absent, shared with the postTtl resolve). - postTtl:
cardConfig.postTtlCardIdis resolved one level deep with the same payload/locale intopostTtl.card; any failure falls back tonoactionwith a warn trace. requiredPayloadis derived, never authored: GraphQL variable declarations (typed,!⇒ required) ∪ identifiers referenced by compute/i18n that nothing upstream produces.
How to contribute
Ground rules (all repos)
- Never push
maindirectly. Branch + PR, always. Never tag releases off unmerged branches (this shipped unreviewed packages before — see Release process). - The contract's source of truth is card-kit. App, Card Service and docs follow it — never fork the vocabulary locally.
- Contract changes are additive-only within schema v1 (see Versioning).
- One contract change = kit types + zod schemas + SPEC table + a demonstrating sample + accept/reject tests, in the same PR (the
types.test-d.tsz.infer lock fails typecheck if types and schemas drift).
Adding a node type, end to end
- card-kit: add the interface to
src/types.tsand the zod shape tosrc/schemas/node.ts(+ any new style keys tosrc/schemas/style.ts'sSTYLE_VALUE_SCHEMAS); extendKNOWN_NODE_TYPESinsrc/version.ts; document it indocs/SPEC.md§2; add a publish-clean sample; add accept + reject tests; follow thedocs/VERSIONING.md7-step checklist; minor version bump. - Release the kit (below), then bump the pins.
- App: add the render branch in
sdui-raw/RawRenderNode.tsx(+ a leaf component file if it's substantial — new files are kebab-case, eslint enforces it); if it's an input, add itskindtoform-store.tsvalidation; add the type toSUPPORTED_NODE_TYPESinsignalResolveService.ts— this is a conscious, separate step; the Record lock exists so a pin bump alone can't claim it; add tests undersdui-raw/__tests__/. - Card Service: bump the kit pin so the publish gate accepts it; on the interactive branch,
node_typesderivation picks it up automatically (run the backfill for old rows). - Rollout awareness: old clients render unknown containers as plain views but unknown leaf nodes as styled empty views. Until caps routing exists, only serve the new node on signals whose audience is known-updated.
Adding an interaction type
Kit: extend KNOWN_INTERACTION_TYPES + any param refinements in src/schemas/action.ts + an ACTIONS.md entry. App: handle it in DhruvaSduiCard.onInteraction before the socket fallback. Remember: on old builds your new type falls through to the socket and sends a stray widget message — there is no caps gate for interaction types yet.
App-side rules
- Runtime imports come from
@sixdis-oolka/card-kit/runtimeonly (~4KB, zod-free). The full kit (validator + zod, ~230KB) is dev-only, and itsrequiremust sit inside anif (__DEV__) {}block — an early-return doesn't get dead-code-eliminated. - App types in
sdui-raw/types.tsare derived from kit types (loosePartial/Omitextensions) — a kit rename breaks apptscat usage sites, which is the point. Don't hand-mirror. - Dispatch policy stays app-side:
collectGatingFields,resolveEntryFieldRefs,freezeRenderFieldRefs,isTapEntryare app functions by design. - After a pin bump:
yarn install(the lockfile doesn't refresh itself), then run the dhruva jest suites. (Known:signalResolveService.test.tsfails to run on main due to a pre-existing RNFB jest-env issue — not your change.)
Card Service rules
- Bun, not Node;
bun test; forward-only migrations applied byscripts/migrate.ts. - Never re-implement SDUI validation — the layout gate is one
validateSduicall by design. requiredPayload/bindSlots/node_typesare derived on save — never accept them from a client.
Docs to keep in sync
| Doc | Owns |
|---|---|
card-kit docs/SPEC.md | Authoring source of truth (humans + LLM agents) |
card-kit docs/ACTIONS.md | Every action shape, numbered 1–15, publish-clean by script |
card-kit docs/BACKEND_EXAMPLES.md | Wire shapes for backend teams |
card-kit docs/VERSIONING.md | Evolution rules + release checklist |
app docs/dhruva/raw-sdui-cards.md | App-side production contract summary |
| this handbook | The cross-repo picture |
Release process card-kit
- Branch (
feat/*/chore/*), make the change, bumppackage.jsonversion in the same PR. - Run locally:
npm test(219 tests) +npm run typecheck(includes thetypes.test-d.tslock). ⚠️ There is no CI on PRs — tests only run in the tag-publish workflow, so local runs are the gate. - PR → review → merge to
main. - Tag the merge commit on main:
git tag vX.Y.Z && git push origin vX.Y.Z(push the one tag, not--tags). Never tag an unmerged branch — that publishes unreviewed code. - The
publish.ymlworkflow verifies tag == package.json version, runs tests + typecheck, and publishes to GitHub Packages with the repo's ownGITHUB_TOKEN. - First-time consumers need read access: package page → Package settings → Manage Actions access.
- Bump the consumers: app
package.jsonpin (git+ssh://…#vX.Y.Z) +yarn install; Card Service pin.
Semver: patch = fixes/docs/samples · minor = additive schema change · major = SDUI_SCHEMA_VERSION bump.
Known gaps & sharp edges
The honest list — each one is a contribution opportunity.
| # | Gap | Impact |
|---|---|---|
| 1 | clientCaps chain broken: Signal Service drops the field; caps routing unowned; Card Service caps code unmerged | New node types can render as empty views on old clients; rollout must be managed manually |
| 2 | Card Service main pins card-kit 0.6.0; forms-era work sits on feat/interactive-cards-card-kit-0-7-0 | Publish gate doesn't know forms/select/plans until merged |
| 3 | post_action: card_change rewrite unimplemented in Card Service | Authoring a card_change today would reach devices unrewritten (client no-ops on the unknown verb) |
| 4 | Old clients route unknown interaction types (share/download) to the socket fallback | Stray widget message per tap on old builds; no caps field to gate it |
| 5 | History restore of send_message-only answered cards regresses to unanswered | Server must resolve the answered variant post-submit |
| 6 | Card Service authoring secret falls back to the serving secret | Any resolver can publish; set CARD_SERVICE_AUTHORING_SECRET in every env |
| 7 | Signal Service catalog call passes signalKey which Card Service ignores (default limit=20) | Silent catalog truncation |
| 8 | Echoed sensitive values aren't occluded in session replay | Authoring rule: never echo sensitive fields |
| 9 | No CI on card-kit PRs (tests run only at tag time) | Local npm test + typecheck are the gate |
| 10 | Kit doc drift: card_id vs cardId in SPEC §5/§6c + BACKEND_EXAMPLES §10; BACKEND_EXAMPLES title says 0.7.0, plan-inputs prose predates the 0.14.0 array shape, caps example omits select; README pins ^0.4.0 and says "5 samples" | Easy first PR: bring the docs up to 0.21.0 |
| 11 | App jest: signalResolveService.test.ts suite fails to run on main (RNFB jest-env, pre-existing) | Don't chase it as your regression |
| 12 | tools/sdui-preview deleted; stale node_modules remains | Rebuild the web preview against the current contract — high-leverage for PMs/designers |