Screen context
Tell the agent which screen the customer is on, which fields are filled and what is failing — so it helps with the actual problem.
This is what makes the agent in-app rather than merely in your app.
In one minute: call useGraineScreen with screen, title, journey and
a fields array where every field has a status. status is what turns "need
a hand?" into naming the field that is wrong. Everything else here is
refinement.
One hook
Pass navigationRef to the provider and the SDK already reports which screen
the customer is on, for every screen, including ones you never touch. What the
route cannot carry is what is on it. That is this hook:
That is the whole integration for a screen. Write it inline — the hook compares by value, so an unchanged screen is never resent and calling it on every render costs nothing.
Two rules, and breaking either one crashes rather than warns: put the hook
below every piece of state it reads (the object is built during render), and
above every early return (if (loading) return <Spinner/> before it changes
the hook order). That is the most common way this integration breaks.
Report the real values. Masking runs on the way out — a screen that pre-redacts only blinds the agent.
What the agent actually reads
Rendered into the prompt on every turn, not once at the start:
One report becomes several lines. fields alone produces both the count of what is
outstanding and the error the agent can offer to fix.
The N of M still need attention line is counted for the agent from your
fields — anything empty, invalid or carrying an error, excluding locked
and optional. The agent answers "what's left?" from that line rather than
tallying the list itself, which is the kind of thing a model gets wrong by one.
The keys
| Key | What it gives the agent |
|---|---|
screen | A stable id. Keep it constant — analytics group on it. |
title | What the agent calls this screen out loud. |
journey | { name, step, of, next }. next is the only one it cannot infer — without it, asked "what comes after this?", it invents a step. |
fields | What is filled, empty or rejected. |
error | A screen-level banner, as opposed to a per-field message. |
data | Anything else, app-specific and free-form. |
availableActions | Narrows what may be called here. Omit it and every action registered on the mounted screen is offered; [] deliberately offers none. |
The five statuses
status | Means | What the agent does |
|---|---|---|
empty | Nothing entered | Offers to fill it |
filled | Has a value | Leaves it alone |
invalid | Rejected — pair with error | Names what is wrong and offers the fix |
pending | In flight | Waits rather than reporting an outcome |
locked | Cannot be changed here | Routes elsewhere instead of offering to type |
locked is the one people skip and the one that prevents a bad conversation: an
OS permission marked empty gets the agent offering to type a value it cannot
type. Marked locked, it tells them where to go.
error is the most valuable key on the page. invalid says something is wrong;
the message the customer is actually looking at says what, so the agent can
offer the fix instead of asking them to read it out.
Add optional: true to a blank nobody has to fill. It stays visible to the
agent but leaves the "still need attention" count, so a finished customer is not
asked for a middle name. A field carrying an error is outstanding regardless.
Name every screen
The free route report is an identifier — kyc_pan, contacts_sync. Ship those
and the agent reads your routing table back to your customers. Give it the words
printed on the screen:
Typing it over your own route union is what makes it stick: a screen added next year fails the build instead of reaching a customer as a slug.
One input at a time
A screen describes itself once; its inputs change one at a time. useGraineField
binds one input where its state lives, merges into the current screen by name,
and withdraws when the input unmounts:
Who the customer is
The screen says where; this says who.
Traits become prompt variables (greet {name}), are masked like everything
else, and land on the conversation as app_user — which is what stops Call
History being a page of session ids.
Prompt variables, from your app's own data
Three places to set them, one merged view, every transport:
| Set it with | When | |
|---|---|---|
| What the app knows at build time | <GraineProvider variables={{ brand: "Acme" }}> | Fixed for the life of the install |
| What the app learns while running | useGraineVariables() / client.setVariables({ plan, cart_total }) | The plan they just upgraded to, the order on screen |
| Who is signed in | identify({ name, plan }) | After sign-in; null on sign-out |
Later rows win where two set the same key. A variable nothing sets is left out of the prompt — the agent never sees an empty placeholder — so write the prompt to read well without it. Values are strings in a sentence — numbers and booleans are flattened, objects are dropped — and everything is masked on the way out. The merged set reaches the next conversation however it connects — chat, voice or WebRTC — so a sign-in after the page loaded is still a name the agent learns. Unknown keys travel and simply substitute nowhere.
Things that should inform, not interrupt
track does not make the agent speak. It lands in the context, so when the
customer asks why their card was refused, the agent already knows. The last 10
are kept.
client.reportEvent({ name, detail }) is the one that may make the agent
speak first. detail must say what to offer — without it the agent reads an
event name aloud, which is worse than silence. Reserve it for a dead end the
customer would want addressed without asking: a save that never reached the
server, an upload that failed twice.
Reporting is a request, not a command. The agent stays quiet while it is talking, while a reply is on its way, for 20 seconds after it last spoke up, and after three unsolicited interventions in a session. Those limits are the difference between help and an assistant people switch off.
Details worth knowing
- It cleans up after itself. The description lives as long as the component
and is withdrawn on unmount, guarded so a screen leaving during a transition
cannot wipe the one that just arrived. Driving the client yourself:
client.setScreen(ctx)/client.clearScreen(ctx.screen)— neversetScreen(null)from a cleanup. - Fast navigation is safe. Every frame carries a sequence number, so a late frame never replaces a newer one, and the newest screen is replayed when the connection rejoins and on return to the foreground.
- Hooks work outside the provider. Screens live under
NavigationContainer; the provider often wraps only the bar. Every reporting hook falls back to the client the provider registered, or one you register once withsetDefaultClient(client). Tap capture is the exception — mount<GraineTouchCapture>at your root, or it only sees taps on the bar. - Keep it small. A few fields per screen. A fifty-row list crowds out the agent's own instructions; send the three rows the customer can see.
- Nothing sensitive leaves. PAN, Aadhaar, card and account numbers, phones and emails are masked before the frame is sent. See Privacy.

