> ## Documentation Index
> Fetch the complete documentation index at: https://docs.notte.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# Act, choose and check

> Run one step of a natural-language instruction, or ask typed questions about the page

> [!TIP]
>
> Read [the Notte skill](https://notte.cc/skill.md) and follow its setup instructions when setting up Notte or building browser automation. This is the shared source for CLI setup, authentication, and the browser-to-code workflow.

`session.act(instruction)` observes the page, lets a **decider** pick one operation (click, type, select an option, close a dialog that covers the page, scroll, press Enter, go back, wait) and its target element, then executes it. `session.choose(question, options)` and `session.check(question)` ask the same decider a typed question about the current page.

The decider never writes selectors or actions: Notte turns the observed page into multiple-choice questions, validates the answers strictly (an answer that is not one of the offered options executes nothing), and maps the chosen option back to a Notte action.

> [!NOTE]
>
> `act()`, `choose()` and `check()` are available in the Python SDK on local sessions (`notte.Session`). Cloud sessions (`client.Session`) do not support them yet.

## Setup

Install the deciders with `pip install "notte[llm]"` (or `pip install notte-llm` next to `notte-sdk`), then configure one of:

| Option | Environment | What decides |
| - | - | - |
| Jev through OpenRouter | `OPENROUTER_API_KEY` (model `~typesafe/jev-latest`) | TypeSafe's Jev classification model: a calibrated probability per option |
| Jev on TypeSafe | `TYPESAFE_API_KEY` (optional `TYPESAFE_MODEL`, default `jev-latest`) | The same model, called on TypeSafe's API directly |
| LLM | `NOTTE_ACT_DECIDER=llm` with `OPENROUTER_API_KEY`, or any OpenAI-compatible endpoint (`NOTTE_LLM_BASE_URL`, `NOTTE_LLM_API_KEY`, `NOTTE_LLM_MODEL`); model `NOTTE_ACT_MODEL`, default `google/gemini-3.1-flash-lite` | Any model of the LLM endpoint, one call per decision with the options as JSON-schema enums |

`act()` picks the first available: `TYPESAFE_API_KEY` (Jev on TypeSafe), then an OpenRouter key (Jev through OpenRouter), then an LLM on another `NOTTE_LLM_*` endpoint. `NOTTE_ACT_DECIDER=jev` or `llm` forces one. With nothing configured, `act()` raises `DeciderNotAvailableError` listing the options.

> [!NOTE]
>
> Jev through OpenRouter uses OpenRouter's decisions endpoint (`/api/alpha/decisions`), which is in alpha. If it is unavailable, set `NOTTE_ACT_DECIDER=llm` to use an LLM decider with the same key.

Jev cannot write text. Text typed into fields is written by a small, fast LLM on the `NOTTE_LLM_*` endpoint (OpenRouter with `OPENROUTER_API_KEY`): `NOTTE_ACT_TEXT_MODEL`, default `inception/mercury-3` on OpenRouter, else the decision model. With `TYPESAFE_API_KEY` alone, `act()` can click and select but not type text. If the writer's provider refuses a value on content-policy grounds, `NOTTE_ACT_MODEL` (default `google/gemini-3.1-flash-lite`) writes it instead. If that also refuses, the step is `blocked` with the reason.

```bash theme={null}
export OPENROUTER_API_KEY=...     # Jev through OpenRouter, and the text writer
# optional
export TYPESAFE_API_KEY=...       # Jev on TypeSafe directly
export NOTTE_ACT_DECIDER=llm      # an LLM decides instead of Jev
export NOTTE_ACT_LABELS=html      # show elements as rendered HTML instead of accessible names
```

### What the decider sees

By default (`NOTTE_ACT_LABELS=accessible`) each element is named by its accessible name, the name a screen reader announces (the observation's `accessible_name`: its `<label>`, `aria-labelledby`, `aria-label`, a button's text, then `alt`, `title` or `placeholder`). The page is sent as its visible text, with each element shown in place as `[I1] Full name`. A label next to a field whose text is the field's name is not repeated as its own line or offered as a separate element. Roles, operations, live state and `<select>` options are the same in both modes. With a server whose observations carry no `accessible_name`, names are approximated from the rendered page (the label-like text next to a field, then the element's own attributes).

`NOTTE_ACT_LABELS=html` sends each element's rendered HTML (`<input name="name">`) and the rendered page instead. On pages where several elements differ only by their label text (two checkboxes with the same `name`, a list of todo toggles), the accessible names are what tells them apart.

Only the elements in the viewport are offered. On a page taller than the viewport, `act()` decides on the text in the viewport, with a line saying there is more above or below, so the decider scrolls to reach the rest. `choose()` and `check()` read the whole page text, marked with the part above, in and below the viewport. Past 12,000 characters, it is cut around the current scroll position, not at the top of the page.

## Act

```python act_local.py theme={null}
import notte

# needs OPENROUTER_API_KEY (Jev through OpenRouter, and the text writer), and `pip install "notte[llm]"`
with notte.Session() as session:
    _ = session.execute(type="goto", url="https://en.wikipedia.org/wiki/Main_Page")

    # one observe -> decide -> execute step per call
    result = session.act("type Ada Lovelace in the search box")
    print(result.status, result.action, result.confidence)
    print(result.timings)  # observe / decide / write / fresh / execute, in ms

    result = session.act("open the Ada Lovelace article")
    assert result.success
```

`act()` returns an `ActResult`:

| Field | Meaning |
| - | - |
| `status` | `executed` (the action ran, see `execution`), `proposed` (`dry_run=True`), `done` (the instruction is already satisfied), `blocked` (no operation can progress), `stale` (the page kept changing between decision and execution; nothing ran) |
| `action`, `execution` | the Notte action and its `ExecutionResult` |
| `operation`, `target` | the chosen operation (`CLICK`, `TYPE_TEXT`, `SELECT`, `CLOSE_DIALOG`, `SCROLL_DOWN`, ...) and element id (`B1`, or `I3:2` for the 2nd option of a select) |
| `value` | the text typed, as written by the model (`{{variables}}` not substituted) |
| `confidence`, `probabilities` | the decider's confidence and the probability of each candidate target |
| `timings` | milliseconds spent observing, deciding, writing text, re-checking the page and executing |
| `fingerprint` | a hash of the page the decision was taken on |

`blocked` raises `NoActionFoundError` unless `raise_on_failure=False` (the default follows the session's `raise_on_failure`). An action that fails to execute raises like `session.execute()` does.

When neither the instruction nor the observed page context or history gives a value for the field the decider picked, nothing is typed: the decider chooses again without that field, and the skipped field is recorded in the history. File inputs offer no operation: `act()` cannot upload a file, so the decider answers `BLOCKED` and the message says so (use `session.execute(type="upload_file", ...)`).

```python act_options.py theme={null}
from notte_core.act import NoActionFoundError

import notte

with notte.Session() as session:
    _ = session.execute(type="goto", url="https://www.saucedemo.com/")

    # values in `variables` replace {{name}} in the typed text after the model call:
    # the model never sees them
    typed = session.act("type {{user}} in the username field", variables={"user": "standard_user"})
    print(typed.value)  # "{{user}}": the template, as the model wrote it

    # choose without executing, then run the proposed action yourself
    proposal = session.act("click the login button", dry_run=True)
    if proposal.action is not None and (proposal.confidence or 0) > 0.8:
        _ = session.execute(proposal.action)

    # refuse uncertain decisions; return instead of raising when nothing fits
    try:
        cart = session.act("open the shopping cart", min_confidence=0.7)
    except NoActionFoundError as error:
        print("blocked:", error.result)
    result = session.act("open the shopping cart", min_confidence=0.7, raise_on_failure=False)
    print(result.status)  # executed, done, blocked or stale
```

* `variables`: the writer answers `{{name}}` and Notte substitutes the value after the model call, so values never reach a model.
* With a vault attached, credential fields get the vault placeholders, swapped for the real secrets at execution. Values the vault typed are masked again in later model requests.
* `min_confidence`: below it, nothing runs (`status="blocked"`).
* `observation`: decide on an observation you already have instead of observing again.
* `decider`: pass a decider explicitly (see [Deciders](#deciders)).

### Live state

`act()` reads the **live state** of form controls (the current value of inputs, checked state, the options of a `<select>`), so the decider sees what a field already holds. Long option lists are cut to their first 50 options; such a list is set by typing the label of any of its options, listed or not. A `<select multiple>` takes one option per `act()`, and each selection replaces the previous one.

## Choose and check

```python choose_check.py theme={null}
import notte

with notte.Session() as session:
    _ = session.execute(type="goto", url="https://news.ycombinator.com")

    # pick one option about the current page, with a probability per option
    choice = session.choose("What kind of page is this?", ["news aggregator", "online shop", "login page"])
    print(choice.choice, choice.probabilities, choice.confidence)

    # options can carry descriptions
    choice = session.choose(
        "Is there a way to sign in?",
        {"link": "a login link is visible", "form": "a login form is visible", "none": "no way to sign in"},
    )

    # the probability that a yes/no question holds on the page
    if session.check("Is the user logged in?") < 0.5:
        print("not logged in")
```

`choose()` returns a `Choice` (`choice`, `probabilities`, `confidence`). `check()` returns the probability that the answer is yes. With an LLM decider, probabilities are not calibrated: the chosen option gets 1.0, so `check()` returns 0.0 or 1.0.

## Loops

`act()` runs one step. To carry out a multi-step instruction, call it until the decider answers `DONE`, with a step budget and a guard against actions that change nothing:

```python act_loop.py theme={null}
from notte_core.act import ActResult

import notte


def act_until_done(session: notte.Session, instruction: str, max_steps: int = 20) -> list[ActResult]:
    results: list[ActResult] = []
    for _ in range(max_steps):
        result = session.act(instruction, raise_on_failure=False)
        results.append(result)
        if result.status != "executed":  # done, blocked or stale
            break
        # 3 actions in a row (WAIT and scrolls aside: the fingerprint has no scroll position) that left the page unchanged: stop
        recent = results[-4:]
        if (
            len(recent) == 4
            and all(r.fingerprint is not None for r in recent)
            and len({r.fingerprint for r in recent}) == 1
            and all(r.operation not in ("WAIT", "SCROLL_DOWN", "SCROLL_UP") for r in recent[:-1])
        ):
            break
    return results


with notte.Session() as session:
    _ = session.execute(type="goto", url="https://en.wikipedia.org/wiki/Main_Page")
    steps = act_until_done(session, "Search Wikipedia for Ada Lovelace and open her article")
    print([(r.status, r.operation) for r in steps])

    # notte ships a fuller version that also stops on actions alternating on one element:
    # `from notte_core.act import act_until_done`
```

`act()` sends the last 10 `act()` actions of the session to the decider, with whether each changed the page and, for an action that failed, its error class (never the error message), so repeating the same instruction makes progress instead of retrying a failed action. When an action leaves a page, its history entry also carries the page text observed before leaving. The decider and field writer can reuse facts from it, such as a verification code read in a help tab, after returning to a form. Protected variables and vault values stay masked in this history too. A `WAIT` returns as soon as the page changes and settles, or after 3 seconds. Other actions return once the page has reacted to them (the requests they started have finished and the page has been quiet for a moment, at most 3 seconds), so the next call sees, for example, the suggestions a search box loads after typing, or the page a client-side link renders.

## Guards

* **Strict answers.** The chosen option must be one of the offered keys, probabilities must cover exactly the offered options, sum to 1 and favor the choice. An invalid answer (or field value) is asked again once; a second one raises `InvalidDecisionError` (or returns `blocked` with `raise_on_failure=False`), and nothing runs.
* **Fresh page.** Right before acting, Notte checks the page still matches the decision: same URL and same target element (text, value, checked state). This costs a few milliseconds (element signatures read by XPath). If the page changed, Notte decides again once (reusing the text already written for the same field), then returns `stale`.
* **One decision, one action.** A decision is never reused after the page changed; `dry_run` proposals are only executed if you call `session.execute(result.action)`.

## Deciders

```python deciders.py theme={null}
from notte_llm import JevDecider, LlmDecider, LlmWriter

import notte

# explicit deciders instead of the environment-based default
# the writer asks `fallback_model` once when its own provider refuses on content-policy grounds
writer = LlmWriter("inception/mercury-3", fallback_model="google/gemini-3.1-flash-lite")
jev = JevDecider(route="openrouter", writer=writer)  # OPENROUTER_API_KEY (route="typesafe": TYPESAFE_API_KEY)
llm = LlmDecider("google/gemini-3.1-flash-lite")  # OPENROUTER_API_KEY

with notte.Session() as session:
    _ = session.execute(type="goto", url="https://en.wikipedia.org/wiki/Main_Page")
    typed = session.act("type Ada Lovelace in the search box", decider=jev)
    submitted = session.act("submit the search", decider=llm)
    print(typed.status, submitted.status)
```

A decider is any object with two async methods (`notte_core.act.Decider`): `choose(state, questions) -> Answers` and `write(context) -> str | None`.

> [!NOTE]
>
> The question set (one operation question plus one target question per operation), the decision and text-writer prompts and the guards are adapted from [jev-ultrafast](https://github.com/browser-use/jev-ultrafast) (MIT License, Copyright (c) 2026 Browser Use).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.