Graine AI

Actions

Let the agent fill a field, move to a step, or retry an upload on the customer's behalf — and tell it honestly whether the change actually landed.

An action is something the agent can do inside your app. It calls one, your code runs, and it waits to hear what happened before saying anything.

That wait is the whole design. What the agent says next depends entirely on whether the field accepted the value.

Declare it first

Actions are declared on the agent, server-side. The app can only implement what has been declared — it can never invent one.

Declare the actions this agent may use
curl -X PATCH https://www.graine.ai/api/embed/config \
  -H "Content-Type: application/json" \
  --cookie "stytch_session_token=…" \
  -d '{
    "agentId": "your-agent-id",
    "appActions": [
      {
        "name": "fill_pan_from_aadhaar",
        "description": "Fill the PAN field using the PAN already linked to the customer'\''s Aadhaar.",
        "parameters": { "type": "object", "properties": {}, "required": [] }
      }
    ]
  }'

Or paste the file your app already ships

Embed & Widgets → App actions → Import from JSON takes the list your app keeps in source control — the SDK's convention is docs/agent-actions.json — and loads every action in one step.

It accepts a bare array, or an object with appActions or actions around one, which is the shape you would send to the API anyway. enum constraints survive the round trip: an action declared with enum: ["light","dark","system"] is one the model cannot get wrong, and the same action without it invites a value your app has to refuse in front of the customer.

Import replaces what is in the panel rather than merging. That is deliberate — the point is to make the dashboard match the file, and merging leaves a stale action declared here, implemented nowhere, and offered to the model anyway.

Nothing is live until you press Save, which is what pushes the catalogue to the agent.

Names must match your useGraineAction handlers exactly. This is the whole reason to import rather than retype: a name one underscore out declares a tool your app will refuse for the life of the release, and the agent will keep trying it.

KeyRules
nameLowercase, digits and underscores, starting with a letter. This is what the model calls.
descriptionWritten for the model. Say when to use it, not how it works.
parametersJSON Schema. Use an empty object when the action takes no arguments.

Declaring is not deploying. Changes reach live agents when the dashboard syncs them — saving the agent does this. Until then, agents serve the catalogue they last received.

Implement it where it belongs

PanScreen.tsx
import { useGraineAction } from "@graineai/inapp-react-native";
 
useGraineAction("fill_pan_from_aadhaar", async () => {
  const value = await lookupPanFromAadhaar();
  setPan(value);
  return { status: "ok", data: { pan: value } };
});

The action exists while the component is mounted and is withdrawn when it unmounts, so the agent is never offered "retry upload" on a screen with no upload.

Answer honestly

The return value decides what the customer is told.

return { status: "ok" };                    // "Done — I've filled that in."
return { status: "refused", message: "PAN is locked after submission." };
return { status: "error", message: "The lookup service is down." };

ok makes the agent tell the customer the thing is done. Returning it when the field silently rejected the value is the worst failure this feature has — the customer is told their form is complete and finds out later that it is not.

A refusal with a reason is usually more useful than a success. "That field is locked after submission" is exactly what the customer needs to hear, and only your code knows it.

Answer quickly

You have six seconds — ACTION_DEADLINE_MS. A handler that has not resolved by then is answered as a timeout on the runtime side, and the agent tells the customer it was not done rather than guessing. A handler that throws is answered as error for the same reason.

Registration is per mounted screen: useGraineAction registers on mount and unregisters on unmount, so the agent is offered only what the screen in front of the customer can do. Register in the screen that performs the action, not at the root.

For anything slower, return immediately and report the outcome as screen state when it finishes:

useGraineAction("retry_upload", () => {
  startUploadInBackground();          // resolves in its own time
  return { status: "ok", message: "Upload restarted — I'll watch it." };
});

The next useGraineScreen update tells the agent how it went. A handler that blocks for ten seconds produces an agent that has already apologised for failing.

What you can build

ActionWhat the agent does
Fill a fieldCompletes KYC details it can look up
NavigateMoves the customer to the step they are asking about
HighlightDraws attention to the exact control they cannot find
RetryRe-runs a failed upload or mandate without making them hunt for it
EscalateHands the conversation to a person, with context attached

Not implemented yet? That is fine

An action declared on the agent but missing from the running app build is refused immediately and the agent explains what to tap instead. Your dashboard and your app releases do not have to ship together.

One agent per surface

Actions are declared on the agent, and the model is offered every one of them on every surface that agent serves.

This matters more than it sounds. available_actions in the screen context narrows what the agent is allowed to do — an action the current screen does not list is refused with not_on_this_screen. It does not narrow the tool list the model sees. Those tools are built once when the call starts, from the whole declared catalogue.

So an agent serving both a website and a mobile app offers the model both sets of tools in both places. The model spends tokens on tools that cannot work, picks worse because the real ones are buried among them, and occasionally calls one and has to recover from a refusal mid-conversation. The runtime caps the catalogue at 24 for exactly this reason.

Give each surface its own agent. AgentEmbed is unique per agent, so each one gets its own publishable key, domain allowlist, appearance and action list — and, just as importantly, its own prompt. A prompt written to guide someone through KYC is not the prompt that should answer questions on a marketing page.

A useful split:

SurfaceAgentActions
Marketing sitesite-assistantscroll_to_section, open_faq, download_app
Mobile appapp-assistantopen_setting, set_language, set_app_lock, …

Both can share a knowledge base. What they must not share is a tool list, because one of them can never use half of it.

Next steps

On this page