Graine AI

Friction detection

The agent notices the customer is stuck — idle on a step, a field rejecting them, going back and forth, rage taps, moving on with blanks, an error banner — and speaks up first. Every rule is JSON, per screen, configured in the dashboard.

The promise of an in-app agent is that it speaks at the right moment. That moment is not "forty-five seconds on any screen": someone reading terms is not stuck; someone whose PAN field has rejected the same value twice is. Friction detection reads what your screens already report and turns it into six named signals the agent can act on.

The six signals

SignalFires whenWhat the agent hears
stalled_on_stepuntouched for idle.afterMsThe customer has not touched the "Details" screen for 45s. The "PAN" field is showing: Invalid format.
repeated_field_errorthe same field shows an error count times in windowMsThe "PAN" field on the "Details" screen has rejected the customer 2 times — it is showing: Invalid format.
looping_between_screensback on the same screen visits times in windowMsThe customer has come back to the "Address" screen 3 times in the last 45s without getting past it.
rage_tapsa tracked tap taps times in windowMsThe customer tapped "pay" 4 times in 1.5s on the "Checkout" screen — it may not be responding.
incomplete_submita tap that means "carry on" while a field is blank or rejectedThe customer pressed "Next" on the "Details" screen with 2 still to fill: PAN number, Date of birth. Tell them what is missing before they hit the error.
screen_errorthe screen reports an error bannerThe "Upload" screen is showing an error: Upload failed.

Each signal is sent to the agent as an app event. The agent still decides whether to speak — never while it is already talking, never inside nudge.cooldownSeconds of its last intervention, never past nudge.maxPerSession — and your app hears the same signal as friction_detected on useGraineEvents.

Nothing needs instrumenting beyond what screen context already reports: fields[].error and status drive the first two rules, screen ids drive the third, and taps drive the fourth — the ones you name with useGraineTap, and, with fab or captureTaps on the provider, every tap the SDK captures on its own (tracked as tap:screen; the sentence then says "tapped the screen" rather than naming a control).

Configure it in the dashboard

Agent → Embed & Widgets → Friction. Every rule is a switch with its numbers under it; screens that are different get their own row; the pacing is its own section. Import or export the whole thing as JSON. Saved rules reach every app and site running the agent with its next session — no release.

{
  "friction": {
    "enabled": true,
    "idle":           { "afterMs": 45000 },
    "repeatedErrors": { "count": 2, "windowMs": 60000 },
    "backAndForth":   { "visits": 3, "windowMs": 45000 },
    "rageTaps":       { "taps": 4, "windowMs": 1500 },
    "incompleteSubmit": { "taps": ["submit", "next", "continue", "save", "proceed",
                                   "apply", "confirm", "done", "pay", "verify"] },
    "screenError":    true,
    "cooldownMs":     60000,
    "nudge":          { "cooldownSeconds": 20, "maxPerSession": 3, "openLauncher": true },
    "screens": {
      "otp":   { "idle": { "afterMs": 20000 },
                 "message": "The customer has been on the OTP screen for {seconds}s without entering a code." },
      "terms": { "ignore": true },
      "review": { "incompleteSubmit": false },
      "home":  { "idle": false }
    }
  }
}
  • Any rule set to false is off. enabled: false switches everything off.
  • A screen listed under screens keeps every rule except what it changes; ignore: true means no detection on that screen at all.
  • message replaces the built-in sentence. Placeholders: {title} {screen} {field} {error} {count} {seconds} {tap}.
  • incompleteSubmit.taps are matched as lowercase substrings of the tap name, so submit also catches submit_application. Give it your own words if your buttons are called something else — agree, book, आगे. Which fields count as missing is not configured: a field you report with status: "empty" or "invalid", or one carrying an error, is outstanding; anything else is not.
  • cooldownMs is the minimum gap between two signals on the same screen.
  • nudge.openLauncher is for apps that draw their own launcher: a signal outside a conversation should open it. The SDK carries the flag; your app decides what "open" means.

Overriding from the app

The dashboard's rules sit under the app's. Pass rules from the app only for the screens it knows better than the dashboard can — the ones with no task on them:

<GraineProvider client={client} friction={{
  screens: {
    home:      { idle: false },
    favourites:{ idle: false },
    demo:      { ignore: true },
  },
}}>

or, on a module-level client, client.configureFriction({...}) at any time. The layering is defaults ← dashboard ← app; client.friction is the resolved result. The older single knob stallAfterMs still works and maps onto idle.

Reacting in the app

useGraineEvents((e) => {
  if (e.type === "friction_detected") {
    analytics.track("agent_friction", { rule: e.name, screen: e.screen });
    if (client.friction.nudge.openLauncher && !inConversation) openAssistant();
  }
});

What Call History shows

Every signal is on the call's timeline in Call History — with whether the agent spoke to it and, if not, why (it was mid-sentence, it had just spoken up, it had reached its limit). Beside it: the screens the customer moved through, the taps you tracked, and every action the agent took with the app's own answer. All of it exports to CSV.

On this page