# @blario/react-native

Blar agent SDK for React Native. It mirrors the web widget's agent: it parses the React component tree into the
web `dom_state` v3 snapshot, hands out its own per-capture ids (`btn-N`, `inp-N`, `scr-N`) and resolves an id back
to its component to act on it.

Pure JS/TS, no native code. Works on the old architecture and on the New Architecture.

## Install

```bash
npm install @blario/react-native react-native-webview
```

With Expo, let it pick the versions that match your SDK:

```bash
npx expo install @blario/react-native react-native-webview @react-native-async-storage/async-storage react-native-safe-area-context
```

Needs `react` >= 18.2 and `react-native` >= 0.74. `react-native-webview` runs the chat (the web widget) and is the
only peer you need to get started; the rest are optional and the SDK works without them:

| Peer | Without it |
| --- | --- |
| `@react-native-async-storage/async-storage` | the contact is kept in memory, so every launch is a new anonymous contact |
| `react-native-safe-area-context` | fixed fallback insets are used instead of the device's |
| `react-native-view-shot` (or `@shopify/react-native-skia` on iOS) | a screenshot request answers `sdk_error` |
| `react-native-screens` | on iOS the agent layer is drawn in the root tree instead of its own window |
| `expo-haptics` or `react-native-haptic-feedback` | no buzz when a confirmation appears |
| `expo-keep-awake` or `react-native-keep-awake` | the screen may sleep during an agent run |

Native peers need a rebuild of the app (`npx expo prebuild` / `pod install`) unless Expo Go already ships them. The
package ships its TypeScript source, which Metro compiles with the rest of your app, plus type declarations in `dist/`.
Then wrap the app as shown in [Connect to the Blar backend](#connect-to-the-blar-backend).

### Versions

Versions follow semver and each one is published to npm when it reaches `main`. While the major is `0` a minor release
may change the API, and the `^0.2.0` range npm writes by default stays within one minor. The `latest` dist-tag is the
newest stable release and `next` the newest prerelease (`0.3.0-beta.0`), installed with
`npm install @blario/react-native@next`. `SDK_VERSION` is the installed version, and it is what the SDK reports to the
backend.

## Integrate

```tsx
import { BlarProvider } from '@blario/react-native';
import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native';

const navigationRef = createNavigationContainerRef();

export default function App() {
  return (
    <BlarProvider navigationRef={navigationRef} relayUrl="ws://localhost:8790">
      <NavigationContainer ref={navigationRef}>{/* app */}</NavigationContainer>
    </BlarProvider>
  );
}
```

`navigationRef` is optional. Without it `navigate` only reaches routes of the navigators on the focused path.
Screenshots use `react-native-view-shot` when it is installed, otherwise `@shopify/react-native-skia`
(`makeImageFromView`) on iOS. Android requires view-shot: Skia's Android capture can raise a fatal native error
when a view unmounts during capture. With no supported capture module, a screenshot answers `sdk_error`.
Each module is resolved once when the SDK loads:

- view-shot leaves a host `Modal` out (it is a separate window), so the response carries `partial: true` and
  `omitted: ['modal']` while one is open.
- Skia on iOS walks the view tree. A host `Modal` is presented by its own view controller, outside the captured
  view, so it is reported as omitted, as with view-shot. A capture narrower than `maxWidth` is sent at its own width,
  never upscaled.

Neither includes the status bar, the chat or the agent layer.

The implicit `ws://localhost:8790` relay is available only in development when no backend settings are provided.
Production requires both `publishableKey` and `apiBaseUrl`, or an explicit `relayUrl`. Invalid or partial backend
settings disable the SDK while the host app keeps rendering. API URLs must use HTTP(S); relay URLs must use WS(S).
Surrounding whitespace and scheme/host casing are normalized, and API base URLs must have no query or fragment.

Installed from npm, Metro needs no configuration. Only a linked checkout (the example app's `file:..` dependency) needs
the SDK folder watched and a single copy of `react`/`react-native` (see its `metro.config.js`).

## Connect to the Blar backend

```tsx
<BlarProvider
  publishableKey="<publishable-key>"
  apiBaseUrl="https://api.blar.io"
  user={{ externalId: 'u-1', email: 'ana@x.cl', name: 'Ana', country: 'CL', getIdentityToken: () => fetchToken() }}
  language="es"
  debug
>
```

`language` is `'es'` or `'en'` and defaults to `'es'`; unsupported runtime values also fall back to `'es'` for the
SDK's own texts and the chat's language. With `publishableKey` and `apiBaseUrl` the provider uses the backend
transport. It mirrors the web widget: `contacts/load/` then `contacts/identify/` with `X-Publishable-Key`,
`X-Source-Id` and the identity proof (`X-Identity-Token` / `X-Identity-Signature`, refreshed once through
`getProof` / `getIdentityToken`), the session socket `/sessions/chat/<id>/?publishable_key&source_id&tab_id` with the
proof as a subprotocol, ping/pong with `{visibility, hidden_ms, lag_ms, sdk_version}`, reconnect with backoff,
response replay after a reconnect, and a presence heartbeat every 30 s while the app is active and touched in the last
3 minutes. `source_id` persists in AsyncStorage when installed, otherwise in memory. `enabled={false}` keeps the SDK
off; changing it on a mounted provider starts or stops the socket, the agent and the chat.

Identity tokens must have three nonempty base64url JWT segments; identity signatures must be hexadecimal.
Malformed static values or provider results are treated as proof failures and never sent in native headers.

```ts
import { Blar } from '@blario/react-native';

await Blar.setUser({ externalId: 'u-1', email: 'ana@x.cl' });
await Blar.logout();
Blar.isConnected();
await Blar.getMessageMetadata({ includeTextDom: true });
```

Risky actions (`irreversible`, `destructive`, or `confirmation_required`) ask for confirmation in a native panel on the
status card; a decline or a 60 s timeout answers `user_cancelled`.

### Drive the app with the real on-site agent

```bash
npm run harness -- --key <publishable-key> --external-id <id> --email <email> --base http://localhost:55180 "crea un cliente llamado Ana Díaz"
harness/tap-confirm.sh Confirmar 240     # approves the confirmation on the emulator (adb + uiautomator)
```

The harness identifies the same contact as the app (`--external-id` and `--email`, or `BLAR_EXTERNAL_ID` and `BLAR_EMAIL`;
`--name` and `--country` are optional), posts the message through
`/api/ai/contact-sessions/<id>/widget/messages/create/` without a `tab_id`, so the app's socket answers as the holder,
and prints the conversation as it grows.

## Chat and nudges

With the backend transport the provider also renders the chat. It needs `react-native-webview` (a peer dependency)
and optionally `react-native-safe-area-context` for insets.

```tsx
<BlarProvider
  publishableKey="<publishable-key>"
  apiBaseUrl="https://api.blar.io"
  user={user}
  language="es"
  strings={{ agentThinking: 'Pensando…' }}
  chat={{ launcher: { visible: true, side: 'left', offsetY: 88 }, primaryColor: '#7C3AED' }}
>
```

```ts
Blar.open();
Blar.close();
Blar.getUnreadCount();
Blar.isChatOpen();
```

- The web support widget runs inside a `react-native-webview` in `messagesOnly` mode, served from a generated HTML
  shell on the API origin. The bundle is generated, not committed (`src/chat/widget-bundle.generated.ts` is git-ignored): build
  `support-widget.min.js` in `web-sdk/`, then run `npm run embed-widget` before `npm run typecheck` or the example
  app (the unit tests do not need it). Or pass `chat.scriptUrl` to load it from a CDN instead.
- One contact, one session, one executor: the page seeds the widget with the native `tab_id`, identifies with the same
  identity and source, and boots it with `agentExecution: false`, so it ignores every agent command and only the native
  runtime executes. Messages the user sends carry `Blar.getMessageMetadata()` (platform, screen path, text tree). The
  widget's `onSessionChange` reports a new conversation and the native socket follows it. When the page leaves its
  session (an escalation, a new or an ended conversation), native ends any run there and drops the socket until the
  page reports the next one. Its `onClose` closes the sheet, and the chat reopens when the sheet does.
- The chat page never navigates away: a link it opens (a message link, an attachment, "Powered by Blar") goes to the
  system browser through `Linking`, and frames inside the page load as usual.
- Replying works as in WhatsApp: drag a message to the right and let go, or hold it, and the composer quotes it. This is
  the widget's own touch gesture, so it needs no native code.
- Assistant messages that arrive while the sheet is not open raise the unread badge.
- Nudges arrive as native bubbles with the same sender, message, progress and actions as the web widget. Accepting
  calls `POST /api/activation/nudges/open/` and opens the chat on the nudge session (`openSession`, without reloading
  the page); dismissing and snoozing call the `dismiss` and `snooze` endpoints. A heartbeat that reports a pending
  nudge refreshes the session so the bubble shows without waiting for a restart.
- `strings` overrides any of the confirmation, chat, nudge and agent mode texts; `es` and `en` defaults ship in the
  package.

## Agent mode

A run looks and behaves like the web widget's agent mode (`web-sdk/src/managers/agent-mode-state.ts`,
`web-sdk/src/agent/agent-mode-ui.ts`), driven by `AgentModeController`:

- The first `agent_command` or `agent_command_batch` of a run starts a 2 s pending phase ("La IA va a tomar el
  control"); its commands are acknowledged at once and run when it ends. Reads (`dom_state`, text tree, screenshots)
  never start a run. A `history` frame with `agent_mode_active` restores a run without the pending phase.
- A run survives the app being killed, like the web's per-tab resume flag. The session id and the time of the last
  agent frame are kept in AsyncStorage while the run is active; connecting to that session again within 120 s (the
  backend's longest wait on an SDK answer) brings the layer back without the pending phase. It then waits 30 s for an
  agent frame, absorbing one completion meant for the killed app. Every exit, a session change and a logout clear it.
- While active the chat sheet collapses, the launcher hides, a status card shows the state ("Pensando", "Ejecutando
  acción...", or the backend's progress label) with a Stop button, and each step's sentence shows as a message bubble
  (markdown, deduplicated, read for 6 to 18 s). A queued message shows a chip on the card for 5 s.
- There is no cursor. Each `click`, `check` and `type` shows a fingertip ring on its target (40 dp, white with a dark
  hairline, filled with the primary colour): it appears on the target, stays pinned to it while it moves, presses and
  sends out one ripple once it settles, and is gone 450 ms later. Scrolls, keys, navigation and highlights draw
  nothing. A click or a switch waits for the landing, at most 1.8 s, before the gesture. The status card fades while a
  tap lands on something underneath it. Reduced motion keeps only fades.
- Readings and confirmations live in a white panel attached to the top of the status card, the two reading as one
  surface. Before it shows, the panel is laid out unseen and its target is scrolled so that it sits in the middle of
  the free area above the stack (the nearest scroll container, by an offset); when the target still sits under the
  stack (a tab bar, a sticky button), the whole stack docks at the top of the screen. The dock is decided once per
  panel. The message is capped at 40 % of the free area and scrolls inside the panel beyond that.
- A `highlight` dims the screen 60 % around a rounded hole over the target (padding 8, tracked while it moves) and
  shows a reading panel: the step's sentence (markdown), a bar filling over the reading time (6 to 18 s at 120 wpm,
  counted from when the panel is on screen) and "Omitir". The status card keeps its text and its Stop. The action is
  answered when the reading ends, so the agent waits for the user; Omitir or the app going to the background ends it
  early, Stop or Back answers `user_cancelled`. The `duration_ms` the backend sends is ignored, as on the web. Step
  bubbles are hidden while the panel is up.
- An action that needs confirmation (`confirmation_required`, or an irreversible or destructive risk; never a
  `highlight` or `scroll_to`) dims around its target, outlined and pulsing with a white edge, and asks in the panel:
  the step's sentence, or the action's own description, then **Detener** and **Confirmar** (48 dp, the confirm text in
  the colour that reads on the primary). The status card says "Confirmación pendiente" and hides its own Stop. A
  destructive action adds "Esta acción no se puede deshacer" and turns the button into a red **Sí, continuar**.
  Detener or Back stops the whole run, as on the web; a tap on the dim backdrop only nudges the panel. 60 s without an
  answer declines it. Both answer `user_cancelled`, with a message that tells a decline from silence. The confirmation
  buzzes once when it appears if `react-native-haptic-feedback` or `expo-haptics` is installed (both optional peers).
  Once approved, what the user saw is captured (JPEG 0.6, before the action runs) and sent on
  `{command_id}:screenshot` as the consent evidence the backend keeps; a failed capture is reported there as
  `CAPTURE_FAILED`, and nothing is sent without a capture module or after a Stop.
- Touches on the app are swallowed by a shield that shows "Toca Detener para tomar el control" and pulses Stop. The
  agent never goes through touches, so its actions are unaffected.
- Stop, or the Android back button, sends `agent_cancel` and ends the run; an action not yet dispatched and the rest
  of a batch stand down. The run also ends on `agent_execution_complete`, `agent_acknowledged`, `agent_cancelled`,
  `session_closed`, a message that waits for the user, a conversation replaced by an objective or any other session
  change, a socket that gave up, or 120 s without agent activity. The chat opens again when it ends.
- The layer lives in its own window: a transparent `Modal` on Android (re-raised when the agent opens a host `Modal`
  on top of it), `FullWindowOverlay` from `react-native-screens` on iOS when installed, the root tree otherwise. It is
  excluded from snapshots and screenshots.
- The screen stays on from the AI turn through the run when `expo-keep-awake` or `react-native-keep-awake` is
  installed (both optional peers); without one this is a no-op, reported once with `debug`.

Known differences from the web: the pending dim covers the whole screen (the web dims only the chat window); the
layer takes window focus, so an open keyboard closes when a run starts; `session_closed` ends the run (the web only
tears it down). Visual effects (dot grid, glow, slime) come in a later milestone.

### Test nudges without a real delivery

```bash
npm run proxy -- --log proxy.log                          # :55181 -> the local backend (HTTP and the session socket)
# EXPO_PUBLIC_BLAR_API_BASE_URL=http://localhost:55181, adb reverse tcp:55181 tcp:55181, restart Metro with --clear
npm run inject -- history --milestone 9001               # a nudge as the history frame the backend sends on connect
npm run inject -- live --milestone 9002                  # the same nudge as a live message frame
npm run inject -- continuation --cta "Continuar" --milestone 9003
```

The proxy forwards everything to the backend untouched, tags the app's own session socket (user agent without
"Mozilla") as native, and `inject` pushes the frame only to that socket. Every request to
`/api/activation/nudges/*` is logged with its headers, body and the backend's answer. `GET /__proxy/state` lists the
open sockets and the last requests.

## Run the example app

`example/` is an Expo SDK 51 app (AgendaPro-like screens, React Navigation) that depends on the SDK through `file:..`
and runs in Expo Go on an Android emulator. Build and embed the widget first (see Develop).

```bash
cd example
cp .env.example .env.local                 # local backend URL, publishable key and test contact; never committed
npm ci
adb reverse tcp:8081 tcp:8081 && adb reverse tcp:55180 tcp:55180
npx expo start --clear                     # open it in Expo Go
```

`EXPO_PUBLIC_*` values are inlined when Metro bundles, so restart it with `--clear` after editing `.env.local`. Without a
publishable key the provider talks to the dev relay instead.

### On the iOS simulator

Keyboard and WebView behaviour only shows on iOS. With Xcode 27, `npx expo run:ios` cannot be used as is: Xcode ships
`DeviceHub.app` instead of the `Simulator.app` Expo 51 looks for, it builds nothing below iOS 15, and the iOS 27 runtime
kills React Native 0.74 at launch (no `UIScene` lifecycle). Use an iOS 26 runtime and build with `xcodebuild`:

```bash
xcodebuild -downloadPlatform iOS -buildVersion 26.0
xcrun simctl create "iPhone 17 (iOS 26)" com.apple.CoreSimulator.SimDeviceType.iPhone-17 com.apple.CoreSimulator.SimRuntime.iOS-26-0
npx expo prebuild -p ios
# ios/ is generated: set "ios.deploymentTarget": "15.1" in ios/Podfile.properties.json, and in the Podfile's
# post_install force IPHONEOS_DEPLOYMENT_TARGET = '15.1' on every installer.pods_project target, then:
(cd ios && pod install)
xcodebuild -workspace ios/agendaproexample.xcworkspace -scheme agendaproexample -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17 (iOS 26)' -derivedDataPath ios/build build
xcrun simctl install "iPhone 17 (iOS 26)" ios/build/Build/Products/Debug-iphonesimulator/agendaproexample.app
npx expo start --clear                     # Metro; the simulator reaches 127.0.0.1 without adb reverse
xcrun simctl launch "iPhone 17 (iOS 26)" io.blar.example
```

`.maestro/` holds [Maestro](https://maestro.dev) flows that tap through the app, chat WebView included
(`brew install mobile-dev-inc/tap/maestro`, needs Java 17): `maestro test .maestro/chat-keyboard.yaml`.

## Run the dev relay

```bash
npm run relay                      # app socket ws://localhost:8790, HTTP http://localhost:8791
adb reverse tcp:8790 tcp:8790      # Android emulator/device reaches the relay on localhost
relay/ctl.sh snap                  # POST /dom_state?text=1
relay/ctl.sh act "Guardar" click   # find the id by text in a fresh snapshot, then POST /command
relay/ctl.sh shot                  # POST /screenshot, saved under relay/shots/
```

| Request | Body | Reply |
| --- | --- | --- |
| `POST /dom_state[?text=1]` | none | `{schema_version, platform, screen, routes, html_snapshot}` or the snapshot text |
| `POST /command` | `{action: {type, ...}, target?: {agent_id}}` | `{success, no_op, error?, message?}` |
| `POST /screenshot` | `{options?: {quality, maxWidth}}` | `{path, bytes, screen}` |
| `GET /health` | none | `{app_connected}` |

Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `request_screenshot` in,
`command_ack` then `sdk_response` (`agent_command_response`, `dom_state_response`, `screenshot_response`) or
`sdk_error` out.

## Actions

| `action.type` | Fields | Target |
| --- | --- | --- |
| `click` | | `btn-N` (a switch toggles, an input is focused) |
| `type` | `value`, `clear_first` (default true) | `inp-N` text input |
| `check` | `checked` (omit to toggle) | `inp-N` switch |
| `press_key` | `key` (`Enter` only) | `inp-N`, or the last input used |
| `scroll` | `direction` up/down/left/right, `amount` px | `scr-N` |
| `scroll_to` | | any element, uses its nearest scroll container |
| `navigate` | `route` (`Name` or `Parent/Child`), `params` | none |
| `highlight` | `duration_ms` (default 3000) | any element |

`click`, `type`, `check` and `highlight` scroll the target into view first. Elements that sit beyond their scroll
viewport are listed with `offscreen=above|below|left|right` (at most 30, nearest first).

## Develop

The chat embeds this repo's widget build, which is generated and git-ignored, so build it before the typecheck or the
example app (the unit tests do not need it).

```bash
(cd ../web-sdk && pnpm install && pnpm build)   # the widget
npm ci
npm run embed-widget                            # src/chat/widget-bundle.generated.ts from ../web-sdk/dist/support-widget.min.js
npm run telemetry-config                        # src/telemetry/build-info.generated.ts from SENTRY_DSN / SENTRY_ENVIRONMENT (empty by default: no reporting)
npm run typecheck
npm test
```

CI (`.github/workflows/react-native-sdk-ci.yaml`) runs the same steps on every change under `react-native-sdk/`, plus
`npm run build`, which emits the type declarations into `dist/` (git-ignored; `prepack` runs it). The widget deploy
workflows ignore those changes.

## Release

A release is a version bump that reaches `main`, like a widget deploy. npm never takes the same version twice, so a push
to `main` publishes only when `package.json` carries a version npm does not have yet; without a bump nothing is
published.

```bash
cd react-native-sdk
npm version minor --no-git-tag-version     # patch | minor | major; rewrites package.json, the lockfile and src/version.ts
git commit -am "Release @blario/react-native 0.2.0"
```

Merge that to `dev`, then `dev` to `main` as usual. For a prerelease use
`npm version preminor --preid beta --no-git-tag-version` (`0.3.0-beta.0`); a version with a `-` is published under the
`next` dist-tag, so `latest` never moves to it.

`.github/workflows/react-native-sdk-release.yaml` runs on pushes to `main` that change `react-native-sdk/package.json`
and stops there when npm already has that version. Otherwise it builds the widget with
`API_BASE_URL=https://api.blar.io` and the repo's Sentry secrets, uploads its source maps and injects the debug IDs (the
same steps as `deplot.yaml`), embeds it, runs the typecheck and the tests, writes the Sentry DSN into the package
(`npm run telemetry-config`), packs the tarball, checks that it holds the embedded widget, `dist/index.d.ts` and the
DSN, and publishes that exact tarball. It uses the `SENTRY_DSN`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG` and `SENTRY_PROJECT`
repository secrets, and a release fails if any is missing. A failed release is retried with "Re-run jobs" on its run. A
published version can never be replaced, so fix a bad release with the next patch and deprecate the bad one
(`npm deprecate @blario/react-native@0.2.0 "use 0.2.1"`).

### First release, once

There is no npm token: the workflow publishes with npm's trusted publishing (OIDC), which needs the package to exist
before it can be configured. The repository is private, so npm does not attach provenance to what it publishes.

1. Reserve the name by hand from a throwaway directory, signed in to npm as a maintainer of `@blario/core` (`berrazuriz1` or `josem7`):
   `npm publish --access public` with a `package.json` of `{ "name": "@blario/react-native", "version": "0.0.0", "license": "MIT" }`.
2. On npmjs.com, open the package, Settings, Trusted Publisher, GitHub Actions: organisation `blarApp`, repository
   `support-web-component`, workflow `react-native-sdk-release.yaml`, no environment, and allow `npm publish`. Save
   (npm does not validate it until the first publish) and publish the first real version within 2 days or the setting
   expires.
3. Optional hardening under Settings, Publishing access: require 2FA and disallow tokens. It does not affect OIDC.
4. Merge `dev` into `main`. `package.json` is at `0.1.0`, which npm does not have, so that push publishes it.

If trusted publishing is not wanted, a granular npm token (Read and write, scoped to `@blario`, 2FA bypass) stored as the
`NODE_AUTH_TOKEN` of the publish step works too, but npm is retiring direct publishing with 2FA-bypass tokens in January
2027.

## Error reporting

Like the web widget, the SDK reports its own contained failures to Blar's Sentry, through a client that is isolated from
the host app: it installs no global error handler and no native crash hook, never touches the app's own Sentry, and
only sees errors the SDK catches itself (the agent runtime, the backend transport and a boundary around the SDK's own
UI, never the app's components). Events carry the SDK version, platform, publishable key, `source_id`, session id and
the user's external id; emails, bearer tokens and identity tokens are scrubbed from messages. Network failures and
refused credentials only become breadcrumbs, and at most 50 events are sent per launch. Without a DSN (the repo
default, set only by the release job) nothing is sent. `enabled={false}` stops the SDK, and its reporting with it.

Stack traces from the app's own bundle cannot be symbolicated on Blar's side, so events group by context and error type
rather than by stack. The embedded chat widget reports as the web widget does, with its source maps uploaded by the
release job. Both use the `production-react-native` Sentry environment.
