# uproot documentation

> Documentation for uproot, a modern framework for browser-based behavioral experiments

---

Source: https://uproot.science/index.md

# uproot

**uproot** is a modern, open-source framework for developing and conducting browser-based behavioral experiments. Build studies with hundreds of participants, from simple surveys to complex real-time multiplayer games.

[**Try the live demo**](https://demo.uproot.science)

**Source code:** [github.com/mrpg/uproot](https://github.com/mrpg/uproot) <br>
**Examples:** [github.com/mrpg/uproot-examples](https://github.com/mrpg/uproot-examples)

!!! warning "Work in progress"

    uproot is in initial development. Breaking changes may occur between releases. The announcements shown in the admin interface will tell you when action is needed.

<div class="grid cards mt-4" markdown>

-   __Get started in minutes__

    ---

    Install uproot with a single command and create your first experiment.

    [Installation](https://uproot.science/getting-started/installation.md)

-   __Learn by doing__

    ---

    Follow the tutorial to build a complete prisoner’s dilemma experiment.

    [Tutorial](https://uproot.science/getting-started/tutorial.md)

-   __Built for multiplayer__

    ---

    Group participants, synchronize progress, and enable real-time interaction.

    [Multiplayer experiments](https://uproot.science/multiplayer/groups.md)

-   __Explore examples__

    ---

    Ready-to-use experiments covering common paradigms and techniques.

    [Example apps](https://github.com/mrpg/uproot-examples)

-   __AI-assisted development__

    ---

    Build experiments faster with Claude Code, Codex, and other agentic coding tools.

    [Set up AI tooling](https://uproot.science/getting-started/installation.md#set-up-ai-assisted-development)

</div>

## Why uproot?

- **Reproducible experiments:** Your project folder plus `uv` recreates the exact software environment of your study, years later.
- **Real-time multiplayer:** Built-in support for grouping participants, synchronization, and live interactions.
- **Flexible data storage:** Append-only log ensures data persistence and supports arbitrary data types.
- **Modern stack:** Built on [FastAPI](https://fastapi.tiangolo.com/) for performance and reliability.
- **100% open source:** LGPL-licensed with no vendor lock-in.

[**The full case for uproot**](https://uproot.science/why-uproot.md)

## Quick example

Here is the complete Python code of a two-player prisoner’s dilemma:

```python
from uproot.fields import *
from uproot.smithereens import *

DESCRIPTION = "Prisoner's dilemma"
SUGGESTED_MULTIPLE = 2

class C:
    PAYOFF_MATRIX = {
        # (you cooperate, other cooperates): your payoff
        (True, True): 10,
        (True, False): 0,
        (False, True): 15,
        (False, False): 3,
    }

class Instructions(Page):
    pass

class GroupPlease(GroupCreatingWait):
    group_size = 2

class Dilemma(Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )

class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group: GroupType) -> None:
        for player in group.players:
            player.payoff = C.PAYOFF_MATRIX[
                player.cooperate,
                player.other_in_group.cooperate,
            ]

class Results(Page):
    pass

page_order = [
    Instructions,
    GroupPlease,
    Dilemma,
    Sync,
    Results,
]
```

[Browse the complete example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma) including HTML templates

## Getting in touch

uproot is developed by **[Max Grossmann](https://max.pm/)** and **[Holger Gerhardt](https://www.econ.uni-bonn.de/iame/gerhardt)**. Click [here](https://uproot.science/legal.md) for more information.

---

Source: https://uproot.science/why-uproot.md

# Why uproot?

If you have ever run a survey or collected data from human participants, you have relied on software to sit between you and your respondents. While doing so, you have probably run into its limitations: fragile JavaScript workarounds, “copy survey” as version control, rigid page sequences, and data-reshaping scripts for every single project.

The quality of the tools we use determines the quality of the science we can do. uproot was built on the conviction that your software should never be the weakest part of your study.

**In one paragraph:** uproot never overwrites your data and remembers where every value came from. It reruns identically years later. Participants see beautiful pages that adapt to any device and work with screen readers. Obvious things work without workarounds (for instance, refreshing a results page can never pay a bonus twice …). It checks everything participants send on the server, where they cannot tamper with it. It handles real-time interaction, flexible page orders, and dropouts out of the box. And it is completely free and open source.

Each claim, explained so you can check it yourself:

## Your data tells its own story

**The advantage in one sentence: uproot never deletes or overwrites your data, and it remembers who changed what, when, and which line of your code did it.**

Most software stores your data like a whiteboard: when a value changes, the old one is erased. uproot stores it like a **lab notebook**: entries are permanent, every entry is dated, and new information is added on a new line. This is called an append-only log.

Concretely, every data change is recorded with three things: a timestamp, the value itself, and the exact place in your study’s code that caused the change. If a participant’s payoff was set to 10 by a function called `set_payoff()` on line 42, that fact is stored forever, next to the payoff itself. When a reviewer, a co-author, or your future self asks, “Where does this number come from?,” the data itself provides the answer.

Two practical consequences experimenters will appreciate:

- Repeated rounds do not create dozens of duplicate columns, so you no longer write reshaping scripts before analysis.
- You can reconstruct any participant’s complete state at any moment of the study, after the fact.

[Storing and accessing data](https://uproot.science/building/data.md)

## Your experiment can be rerun, exactly, years later

**The advantage in one sentence: if you use uproot as intended—with the tool `uv`—anyone with a copy of your project folder can rerun your experiment with exactly the software you used, years later, with one command.**

[Reproducibility](https://en.wikipedia.org/wiki/Reproducibility) is a cornerstone of science, but with most platforms it is out of your hands: the platform updates whenever the vendor decides, and there is no record of what exactly you ran. A Qualtrics survey from 2022 runs on whatever Qualtrics is today.

With uproot, reproducibility is not an extra chore. It falls out of the normal workflow:

1. **Your project folder *is* your experiment.** Pages, logic, texts, and settings all live in one ordinary folder that you can copy, archive, and share.
2. **A file in that folder, `uv.lock`, lists the exact version of every piece of software your project uses.** This includes uproot itself and everything underneath it. Think of it as a receipt for your software environment. It is written automatically; you never edit it.
3. **One command, `uv sync`, reinstalls exactly what is on that receipt.** Not “the current version,” but *your* version—on your laptop, on a lab server, or on a replicator’s machine in 2031.
4. **Nothing updates behind your back.** Your environment changes only when you explicitly ask for an upgrade. Mid-study, it stays frozen.
5. **No hidden moving parts.** uproot ships all browser assets (fonts, styling, JavaScript libraries) inside the package itself, so your study does not silently depend on external servers that may change or vanish (or learn who your participants are; see [below](#your-participants-talk-only-to-your-server)).

Together with the permanent data log above, this covers both halves of a replicable experiment: the *software* that produced your data, and the *complete history* of the data itself.

!!! tip "The one habit that makes it work"

    Keep `uv.lock` together with your project—under version control, in your archive, in your replication package. `uproot setup` initializes a [Git](https://git-scm.com/) repository for you, and `uv.lock` belongs in it. That is the entire discipline required.

## A polished interface that everyone can use, on any device

**The advantage in one sentence: your participants see beautiful, professional pages that work equally well on a laptop, a phone, or a screen reader. You get all of this without doing any design work.**

Building a good-looking study usually means fiddling with layouts, testing on phones, and hoping nothing breaks. In uproot, you describe *what* you want to ask, and the framework takes care of *how it looks*. A seven-point happiness scale, with labeled endpoints, is this, and nothing more:

```python
happiness = LikertField(
    label="How happy are you today?",
    min=1, max=7,
    label_min="Not at all happy",
    label_max="Very happy",
)
```

On a desktop, this appears as a neat horizontal scale. On a phone, the same scale automatically rearranges itself into a vertical layout that fits the screen. You never design two versions. The same is true for every page uproot produces: the interface is fully responsive, meaning it adapts itself to any screen size.

Harder things stay just as short. A whole Likert *matrix*—several questions sharing one scale, a layout that is notoriously painful elsewhere—is a [single template line](https://uproot.science/building/forms.md#scales-and-sliders). So are [file uploads](https://uproot.science/advanced/uploads.md), [sliders, and validated bank-account fields](https://uproot.science/reference/fields.md). You can see all of them live in the [input elements example](https://github.com/mrpg/uproot-examples/tree/main/input_elements).

And the pages work for *everyone*, because accessibility is built in rather than left as homework:

- Participants using screen readers hear every question, every explanation, and every error message read aloud correctly.
- Participants navigating by keyboard alone can skip past repeated content and operate every input.
- Time limits are announced to screen-reader users, not just shown visually.

(For the technically inclined: uproot’s default templates implement this with standard [WAI-ARIA](https://www.w3.org/WAI/standards-guidelines/aria/) markup: labeled inputs, radio groups with accessible names, live announcements, and correctly marked-up dialogs and tables.)

This matters scientifically as well as ethically: participants who use screen readers, keyboards, or five-year-old phones belong in your sample. The visual foundation is [Bootstrap 5](https://getbootstrap.com/), bundled with uproot itself, so pages look consistent and professional out of the box.

[Form fields](https://uproot.science/reference/fields.md)

## Obvious things work without workarounds

**The advantage in one sentence: on other platforms, perfectly ordinary needs—such as “pay this bonus once,” “let participants go back,” “repeat these pages”—require workarounds; in uproot, the obvious way is the correct way.**

!!! quote "The principle of verisimilitude"

    uproot follows the principle that **if something is hard, it’s a bug.**

We call this the principle of *verisimilitude*. The bug is not yours; it is uproot’s. If an ordinary research need is hard to express, the framework is what gets fixed.

A concrete example. On other platforms, a common pattern is to add a €5 bonus when the results page is shown. The hidden flaw: Every time the participant refreshes the page, the code runs again, and the participant receives another €5. Bugs like this have gone undetected for entire studies, and the standard fix is manual bookkeeping that every researcher must remember to write, every time. In uproot, no workaround is needed: Its page lifecycle includes methods that are **guaranteed to run exactly once** per participant, no matter how often a page is reloaded. You write the natural code; the framework makes it correct.

The same philosophy runs through everything else on this page: back buttons, randomized blocks, repeated rounds, dropout handling are things that a researcher obviously needs, so they are built in, not bolted on with tricks.

[Page methods](https://uproot.science/reference/page-methods.md)

## Participants cannot tamper with your study

**The advantage in one sentence: Everything a participant sends is checked on the server, where participants have no access—not in their browser, where they do.**

On platforms whose logic runs in the participant’s browser, a tech-savvy participant can open the developer tools and modify any value: payoffs, treatments, answers. There is no way to prevent this, because the checking happens on hardware the participant controls.

In uproot, the server decides what is valid. Every piece of data a participant sends is validated against the types declared in your code before it reaches your logic:

```python
@live
async def bid(page, player, price: float):
    player.bid = price
```

This says: the `bid` action accepts one thing, a number. Anything else, such as text, malformed data, manipulated requests, is rejected before your study logic ever sees it. You get this protection by writing ordinary Python; no security expertise required.

[Live methods](https://uproot.science/building/live-methods.md)

## Your participants talk only to your server

**The advantage in one sentence: uproot never makes a participant’s browser contact anyone but you—no font services, no content delivery networks, no trackers.**

Most websites, including most survey platforms, quietly load pieces of themselves from other companies’ servers: fonts from Google, styling and scripts from content delivery networks (CDNs), icons from somewhere else again. These requests disclose the participant’s IP address to a third party. They can also reveal browser details and which website the participant is visiting. Under the GDPR, this is a transfer of personal data.

uproot avoids the problem entirely. Every font, stylesheet, icon, and script that uproot uses ships inside the uproot package and is served by your own uproot server. A participant’s browser connects to your server and to nothing else. This has three practical consequences:

- **Simpler data-protection paperwork.** The answer to “Which third parties receive participant data?” is “None,” unless you add some yourself.
- **Labs without internet access work.** A lab network that only connects participant computers to your server is enough; nothing breaks because a CDN is unreachable.
- **No silent changes.** A file that someone else hosts can change or disappear at any time. Files inside the package change only when you upgrade (see [reproducibility](#your-experiment-can-be-rerun-exactly-years-later)).

This guarantee covers uproot itself. Your own pages may of course embed a YouTube video or load an external library if your study needs it; that is your decision, not the framework’s.

The uproot server itself is equally discreet. It contacts uproot.science only when an admin explicitly asks it to check for announcements (or submits praise), never on its own. To rule out even that, set `upd.UPSTREAM = False` in `main.py`.

[The admin interface](https://uproot.science/running/admin.md#announcements)

## Real interaction between participants, built in

**The advantage in one sentence: Participants can interact live—chatting, trading, drawing, negotiating—because uproot keeps a permanent connection to every participant’s browser.**

Updates appear on participants’ screens instantly, without page reloads. This is not a bolted-on extra; it is the foundation the framework is built on. Working examples you can run today include a [double auction](https://github.com/mrpg/uproot-examples/tree/main/double_auction) with live order books, a [collaborative drawing board](https://github.com/mrpg/uproot-examples/tree/main/drawing_board), and [built-in chat](https://uproot.science/multiplayer/chat.md) with pseudonyms for anonymity.

uproot also notices when a participant disconnects and lets you [decide what happens next](https://uproot.science/advanced/dropouts.md). Moreover, groups are formed based on who actually shows up, not who signed up.

[Multiplayer experiments](https://uproot.science/multiplayer/groups.md)

## Your design is not forced into a fixed pipeline

**The advantage in one sentence: The order of pages is a list your code can change while the study runs; so back buttons, adaptive designs, and randomized blocks are normal, not workarounds.**

On most platforms, participants march through a fixed sequence of pages, forward only. In uproot, the [page order](https://uproot.science/building/pages.md) can change at runtime: participants can [go back and revise answers](https://github.com/mrpg/uproot-examples/tree/main/revise), pages can appear or disappear based on earlier choices, and common designs are one line:

```python
page_order = [Hello, RandomOrder(Page1, Page2), Rounds(Task, Feedback, n=4), Bye]
```

This line shows two pages in random order, then repeats a task-and-feedback block four times. Randomized blocks, between-subjects treatments, and indefinite repetition (for random stopping rules without deception!) are all built in.

[SmoothOperators](https://uproot.science/building/operators.md)

## Full control on the day of your study

**The advantage in one sentence: When a live session hits a problem, you can fix it from the admin interface—per participant, in real time—instead of aborting the session.**

From the admin interface you can advance, revert, reload, or regroup individual participants, message anyone privately (you may allow them to respond), and watch everyone’s progress live, filtering and sorting by any data field.

Even if you do need to make live changes to your code, uproot will reload them immediately. (If an error occurs while reloading a Python module, the old continues to run, so that your server will never crash.)

When the study is done, your data exports in [four formats](https://uproot.science/running/export.md) matched to different analyses: from “one row per participant” to the complete event-by-event history.

[The admin interface](https://uproot.science/running/admin.md)

## Built for the AI age

**The advantage in one sentence: you can describe a study in plain language and have an AI coding assistant generate a working uproot version of it.**

Studies are written in [Python](https://www.python.org/) and HTML. These are the two languages with the broadest ecosystem support, and the ones that AI coding assistants handle best. The [uproot skill](https://github.com/mrpg/uproot-skill) gives assistants such as [Claude Code](https://claude.com/claude-code) and [Codex](https://openai.com/codex/) access to the full documentation and all examples, so “build a [public goods game with punishment](https://github.com/mrpg/uproot-examples/tree/main/pgg_punishment) for 4 players” produces a working study you then review and refine. The same mechanism can convert existing Qualtrics, z-Tree, or oTree studies.

The dozens of examples are also yours in the fullest sense: licensed under [0BSD](https://opensource.org/license/0bsd), meaning you may copy, modify, and use them without restriction or attribution.

[Browse the examples](https://github.com/mrpg/uproot-examples)

## High performance

uproot is seriously fast. On simple pages, uproot can easily handle more than 600 requests per second.

Even complex data structures or custom database entries are stored rapidly if you use the user-friendly default deployment. No special software or hardware needed.

[Deploying uproot experiments](https://uproot.science/running/deployment.md)

## Free, open, and yours

**The advantage in one sentence: uproot costs nothing, hides nothing, and locks you into nothing.**

uproot was built by [academics](https://uproot.science/legal.md#who-created-uproot) with no financial stake in the promotion of uproot. If you do not want to use uproot, that is also fine!

To protect uproot and its community, our software is 100% [Free/Libre Open Source Software](https://www.gnu.org/philosophy/free-sw.html). This means you have the [four freedoms](https://www.gnu.org/philosophy/free-sw.html#four-freedoms) guaranteed by the [LGPL](https://www.gnu.org/licenses/lgpl-3.0.html): to run, study, share, and improve the software. There are no license fees, no per-response pricing, and no vendor who can retire a feature you depend on. You can inspect [every line of the code](https://github.com/mrpg/uproot), host it on your own infrastructure (a small server is plenty—the default database is [SQLite](https://sqlite.org/), which requires zero setup), and extend it freely.

## See for yourself

Three commands and you have a running study on your own machine:

[Installation](https://uproot.science/getting-started/installation.md)

---

Source: https://uproot.science/help.md

# Getting help

Relax and move slowly. Most problems with experiments turn out to be small things, such as a missing field, a wrong variable name, a misunderstood page method. Taking a step back and reading carefully will almost always get you further than rushing. The steps below are in order of what to try first.

## 1. Read the documentation

Start here. This documentation covers almost all of uproot’s features with explanations and code examples. Use the search function and navigate by topic—chances are your question is already answered. Pay particular attention to the [reference section](https://uproot.science/reference/fields.md) for detailed specifications.

For complete, runnable examples, browse the [uproot-examples](https://github.com/mrpg/uproot-examples) repository.

Advanced users can also browse the [uproot source code](https://github.com/mrpg/uproot) directly and search through it for particular keywords. Sometimes reading the implementation is the fastest way to understand a behavior.

## 2. Ask a coding agent

AI coding agents like [Claude Code](https://claude.ai/code), [Codex](https://openai.com/index/introducing-codex/), and similar tools are excellent at helping with uproot. They can debug experiments, explain page lifecycle behavior, write form logic, and work through multiplayer synchronization issues.

If your coding agent supports skills (Claude Code, Codex, and others do), install the **uproot skill** for best results:

[mrpg/uproot-skill](https://github.com/mrpg/uproot-skill)

This skill gives the agent deep knowledge of uproot’s API, page lifecycle, fields, SmoothOperators, and multiplayer features. See the [installation guide](https://uproot.science/getting-started/installation.md#set-up-ai-assisted-development) for setup instructions.

If your coding agent does not support skills, this prompt provides useful context:

> I’m building a behavioral experiment using the uproot framework (https://github.com/mrpg/uproot). Documentation is at https://uproot.science/ (an index for AI agents is at https://uproot.science/llms.txt, and every page is available as Markdown by appending `.md` to its path) and example experiments are at https://github.com/mrpg/uproot-examples. The documentation source is at https://github.com/mrpg/uproot-docs. You can clone it for full context.
>
> [Describe your problem here.]

!!! tip

    Cloning the [uproot-docs](https://github.com/mrpg/uproot-docs) repository alongside your project gives any coding agent direct access to all documentation source files, which tends to produce much better answers.

## 3. Email Max and Holger

If you have read the docs, asked an AI, and are still stuck, write to us. Email **both** Max and Holger jointly so that whoever is available can respond and neither of us loses context. You can find our contact details on the [legal information](https://uproot.science/legal.md#contact) page.

We are happy to help, and we mean it. Especially if your question reveals a gap in the documentation, or even a bug in uproot itself, your experience helps us improve uproot for everyone.

!!! note

    All issues raised via email are treated anonymously.

## 4. File an issue on GitHub

If you have identified a concrete bug in uproot—something that is broken, not just unclear—please [open an issue on GitHub](https://github.com/mrpg/uproot/issues). There is a low bar for filing issues: you do not need to follow any particular template or protocol. Just describe what you observed and what you expected. A minimal reproduction helps, but is not required.

This step is specifically for bugs, not for usage questions. For usage questions, email us instead.

## Why there is no forum

You might wonder why uproot has no community forum, Discord server, or discussion board. This is a deliberate choice, not an oversight.

**Correct answers should live in one place.** A forum accumulates answers of varying quality, age, and correctness. Over time, outdated advice coexists with current advice, and users cannot tell which is which. Documentation, by contrast, can be maintained as a single source of truth. When we learn about a common problem or a clever approach, we add it to the docs—where it stays correct and findable.

**Forums fragment knowledge.** The same question gets asked and answered differently across multiple threads. Search engines surface forum posts that may contradict the documentation. This is confusing for newcomers and costly for everyone.

**Quality requires curation.** A forum needs active moderation to remain useful. This includes removing spam, correcting wrong answers, merging duplicates, updating stale threads. This is a substantial ongoing commitment. Unmoderated forums degrade quickly and can become actively harmful when incorrect answers go unchallenged.

**Security and operational burden.** Running a forum means maintaining another piece of infrastructure with user accounts, authentication, and stored data. This creates attack surface and privacy obligations that are disproportionate for a project of uproot’s size.

**Direct contact scales better than you might think.** For a focused framework like uproot, the volume of genuinely novel questions is manageable. Most questions are already answered by the docs or an AI agent. The few that remain are often interesting enough that we want to hear about them directly, since they tend to reveal real gaps in the documentation or the framework itself.

**Your questions improve the docs.** When you email us, your problem often points to something we can clarify or document better. This benefits all future users, not just the people who happen to read a specific forum thread. Every question that reaches us is an opportunity to make uproot easier to use for everyone.

So: if you are stuck, just write to us. We would rather answer your email and improve the docs than maintain a forum.

---

Source: https://uproot.science/legal.md

# Legal information

uproot is © Max R. P. Grossmann, Holger Gerhardt, et al., 2026.

## What is uproot?

uproot is an open-source framework for developing and conducting browser-based behavioral experiments. It is designed for researchers who need to run studies with human participants, from simple surveys to complex real-time multiplayer interactions, in the lab or online.

## Who created uproot?

uproot was originally developed by [Dr Max Grossmann](https://max.pm/) and [Dr Holger Gerhardt](https://www.econ.uni-bonn.de/iame/en/team/gerhardt). We are academic researchers who were not satisfied with existing solutions for running behavioral experiments and set out to build something better.

We do not sell anything. We do not have big funders. uproot is Free/Libre Open Source Software under licenses approved by both the OSF and the FSF and will always remain so. uproot is [open to contributions](https://github.com/mrpg/uproot).

## License

uproot is licensed under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.en.html) (LGPL-3.0-or-later). This means you can use uproot freely in your research, including in proprietary projects, as long as modifications to uproot itself are shared under the same license.

The documentation and code examples are licensed under the [0BSD license](https://opensource.org/licenses/0BSD), allowing unrestricted use without attribution.

## Warranty disclaimer

uproot is provided “as is,” without warranty of any kind, express or implied. To the extent permitted by Law, we disclaim all warranties, including but not limited to fitness for a particular purpose and non-infringement. **Use uproot at your own risk.**

## Contact

[Dr. Max R. P. Grossmann](https://max.pm/contact/) <br>
[Dr. Holger Gerhardt](https://www.econ.uni-bonn.de/iame/en/team/gerhardt)

---

Source: https://uproot.science/getting-started/installation.md

# Installation

This guide uses [uv](https://docs.astral.sh/uv/), a fast Python package manager that handles Python installation automatically. If you prefer traditional tools, see the [pip installation guide](https://github.com/mrpg/uproot/blob/main/INSTALLATION-PIP.md).

## 1. Install uv

=== "macOS / Linux"

    ```console
    curl -LsSf https://astral.sh/uv/install.sh | sh
    ```

=== "Windows (PowerShell)"

    ```powershell
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
    ```

??? note "Other installation methods"
    - **macOS with Homebrew**: `brew install uv`
    - **Windows with winget**: `winget install --id=astral-sh.uv -e`

    See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more options.

After installation, you may need to restart your terminal or run `source ~/.bashrc` (Linux) / `source ~/.zshrc` (macOS) for the `uv` command to be available.

## 2. Create a project

Run the following command to create a new uproot project:

```console
uv run --with uproot-science uproot setup my_project
```

This command

1. downloads and installs the latest version of uproot;
2. creates a new directory called `my_project`;
3. generates starter files including a sample experiment.

You should see output like this:

```
📂 A new project has been created in 'my_project'.
✅ 'main.py' and some other files have been written.
...
```

## 3. Run uproot

Navigate to your new project and run the command shown in the output above:

```console
cd my_project
uv run uproot run
```

The first run will set up a virtual environment and install dependencies automatically. You will see:

```
INFO:     Started server process [...]
INFO:     Waiting for application startup.
INFO:uproot:This is uproot 0.0.1 (https://uproot.science/)
INFO:uproot:Server is running at http://127.0.0.1:8000/
```

!!! tip
    You can stop the server with ++ctrl+c++.

## 4. Access the admin interface

The server output includes an auto-login URL that looks like:

```
Auto login:
     http://127.0.0.1:8000/admin/login/#aBcDeFgHiJkL...
```

Click that URL or copy it to your browser to log in automatically. This auto-login feature only appears when using the default administrator with an empty password.

## 5. Try the sample experiment

Your new project includes a sample experiment: a two-player prisoner’s dilemma. The [tutorial](https://uproot.science/getting-started/tutorial.md) shows you how to build it yourself.

Once logged in, you can start it from the admin interface.

## Set up AI-assisted development

We recommend developing uproot experiments with an agentic coding tool such as [Claude Code](https://claude.ai/code) or [Codex](https://openai.com/index/introducing-codex/). These tools understand uproot’s patterns and can build pages, write form logic, set up multiplayer interactions, and debug issues far faster than working from scratch.

If your coding agent supports skills (Claude Code, Codex, and others do), install the **uproot skill** to give it deep knowledge of uproot’s API:

[mrpg/uproot-skill](https://github.com/mrpg/uproot-skill)

Follow the instructions in the repository to set it up. Once installed, the agent can work with uproot’s page lifecycle, fields, SmoothOperators, data model, and multiplayer features out of the box.

!!! tip

    Even without the skill, agentic coding tools work well if you clone the [uproot-examples](https://github.com/mrpg/uproot-examples) and [uproot-docs](https://github.com/mrpg/uproot-docs) repositories alongside your project. This gives the agent direct access to a lot of important information. Just tell your agent to “kindly inhale the examples and the docs.”

## What’s next?

Now that uproot is running, you can

- **[follow the tutorial](https://uproot.science/getting-started/tutorial.md):** build a complete experiment step by step;
- **[explore the project structure](https://uproot.science/getting-started/project-structure.md):** understand the generated files;
- **[browse example apps](https://github.com/mrpg/uproot-examples):** see complete experiments you can learn from.

## Troubleshooting

### `uv` command not found

If you get “command not found” after installing uv:

- Close and reopen your terminal.
- Or run `source ~/.bashrc` (Linux) / `source ~/.zshrc` (macOS).
- Or add `~/.local/bin` to your PATH manually.

### Permission errors on Linux

If you encounter permission errors, make sure you are not running as root. uv is designed to work in user space.

### Port already in use

If port 8000 is already in use, you can specify a different port:

```console
uv run uproot run --port 8001
```

### Getting help

See [here](https://uproot.science/help.md).

---

Source: https://uproot.science/getting-started/tutorial.md

# Tutorial: A complete experiment

This tutorial walks you through building a complete prisoner’s dilemma experiment from scratch. By the end, you will understand how uproot pages, forms, groups, and synchronization work together.

## What we are building

Two participants are paired. Each of them privately decides whether to cooperate. Then both see what happened and what they earned. In each cell of this payoff matrix, the first amount goes to you and the second to the other participant:

<style>
    .md-typeset__table td {
        font-variant-numeric: tabular-nums !important;
    }
</style>

| | The other participant cooperates | The other participant does not cooperate |
|--|--|--|
| **You cooperate** | $10 / $10 | &numsp;$0 / $15 |
| **You do not cooperate** | $15 / &numsp;$0 | &numsp;$3 / &numsp;$3 |

Every new uproot project already contains this experiment as a sample app called `prisoners_dilemma`. In this tutorial, you build the same app yourself, step by step, under the name `my_dilemma`.

## 1. Create a project

If you have not done so yet, create a new uproot project (see [Installation](https://uproot.science/getting-started/installation.md)):

```console
uv run --with uproot-science uproot setup my_project
cd my_project
```

## 2. Create the app

An uproot experiment consists of one or more apps. Each app is a folder with Python code and HTML templates. Create a new, nearly empty app:

```console
uv run uproot new my_dilemma --minimal
```

This creates a `my_dilemma/` folder with three files:

- `__init__.py` holds the app’s Python code;
- `FirstPage.html` is the template of a placeholder page;
- `simulate.js` lets uproot click through the app for you (see [step 7](#7-test-with-simulated-participants)).

You will not need the placeholder page, so delete `FirstPage.html`.

## 3. Register the config

A config is a named sequence of apps that participants go through. Open `main.py` and add a config for your app below the existing `load_config` line:

```python
load_config(uproot_server, config="my_dilemma", apps=["my_dilemma"])
```

## 4. Write the app

Open `my_dilemma/__init__.py`. Keep the comment at the top of the file: its “Third-party dependencies” lines must stay in every uproot app. Also keep the two `import` lines below it. Delete everything else. You will now add the app’s parts one by one.

### Description and constants

```python
DESCRIPTION = "Prisoner's dilemma"
SUGGESTED_MULTIPLE = 2

class C:
    PAYOFF_MATRIX = {
        # (you cooperate, other cooperates): your payoff
        (True, True): 10,
        (True, False): 0,
        (False, True): 15,
        (False, False): 3,
    }
```

`DESCRIPTION` shows up in the admin interface. `SUGGESTED_MULTIPLE` tells the admin that sessions should have a multiple of 2 players. `C` holds constants that you can use both in Python and in templates.

`PAYOFF_MATRIX` is a Python [dictionary](https://docs.python.org/3/tutorial/datastructures.html#dictionaries). Each key is a pair of decisions: `True` for cooperating and `False` for not cooperating. The first decision is always that of the participant who receives the payoff. For example, `(True, False): 0` means: if you cooperate and the other participant does not, you get 0.

### Instructions

```python
class Instructions(Page):
    pass
```

Each class based on `Page` is a page that participants see. `pass` means that the page needs no Python code: it only shows its template, which you will create in [step 5](#5-create-the-templates).

### Group formation

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2
```

`GroupPlease` is a wait page. When a participant reaches it, they wait until another participant arrives. Then both are grouped together and advance.

### The decision page

```python
class Dilemma(Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )
```

The `fields` dictionary defines the form. After submission, the choice is saved as `player.cooperate`: `True` for “Yes” and `False` for “No”.

### Payoffs

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group: GroupType) -> None:
        for player in group.players:
            player.payoff = C.PAYOFF_MATRIX[
                player.cooperate,
                player.other_in_group.cooperate,
            ]
```

`Sync` is another wait page: each participant waits here until both members of the group have decided. Then uproot runs `all_here` once for the group. It goes through both players, looks up each one’s payoff in `C.PAYOFF_MATRIX`, and saves it as `player.payoff`. `player.other_in_group` is the other member of a two-person group. Because the payoff is saved on `player`, it also ends up in your [data export](https://uproot.science/running/export.md).

`@classmethod` marks `all_here` as a [class method](https://docs.python.org/3/library/functions.html#classmethod). uproot page methods are always written this way, with `page` as their first argument. `group: GroupType` and `-> None` are optional [type hints](https://docs.python.org/3/library/typing.html): they say what goes into and comes out of the method, so that tools can check your code.

### The results page

```python
class Results(Page):
    pass
```

Like `Instructions`, the results page only shows its template.

### Page order

```python
page_order = [
    Instructions,
    GroupPlease,
    Dilemma,
    Sync,
    Results,
]
```

`page_order` lists the pages in the order participants go through them. Participants only see the wait pages `GroupPlease` and `Sync` while they have to wait.

## 5. Create the templates

Every page that participants see needs an HTML template with the same name as its class. Wait pages already have one built in. uproot templates use [Jinja](https://jinja.palletsprojects.com/en/stable/templates/): `{{ ... }}` prints a value, and `{% ... %}` runs logic such as `if` and `for`. Every template can access `player` and `C`.

### Instructions.html

Create `my_dilemma/Instructions.html`:

```html+jinja
{% extends "Base.html" %}

{% block title %}
Instructions
{% endblock title %}

{% block main %}

<p>In this study, you will be paired with another participant.</p>

<p>You will each make one private choice: to cooperate or not to cooperate.</p>

<p>There is no opportunity to communicate with the other participant. Neither of you will learn the other’s choice until both decisions have been submitted. Please make your choice on the next page.</p>

<p>The two decisions jointly determine both your payoff and the other participant’s payoff as follows:</p>

<table class="table">
    <thead>
        <tr>
            <th></th>
            <th>The other participant cooperates</th>
            <th>The other participant does not cooperate</th>
        </tr>
    </thead>
    <tbody>
        {% for you in [True, False] %}
        <tr>
            <th>{{ "You cooperate" if you else "You do not cooperate" }}</th>
            {% for other in [True, False] %}
            <td>
                {{ C.PAYOFF_MATRIX[you, other] | fmtnum(pre="$", places=2) }} for you<br>
                {{ C.PAYOFF_MATRIX[other, you] | fmtnum(pre="$", places=2) }} for the other participant
            </td>
            {% endfor %}
        </tr>
        {% endfor %}
    </tbody>
</table>

{% endblock main %}
```

The table goes through both of your possible decisions (`you`) and both of the other participant’s (`other`). Your payoff is `C.PAYOFF_MATRIX[you, other]`. The other participant’s payoff is `C.PAYOFF_MATRIX[other, you]`, because the first decision is always that of the participant who receives the payoff. The [`fmtnum` filter](https://uproot.science/building/results.md#the-fmtnum-filter) prints each amount as dollars with two decimal places, for example `$10.00`.

### Dilemma.html

Create `my_dilemma/Dilemma.html`:

```html+jinja
{% extends "Base.html" %}

{% block title %}
Your decision
{% endblock title %}

{% block main %}

{{ fields() }}

{% endblock main %}
```

`{{ fields() }}` renders all form fields defined on the page.

### Results.html

Create `my_dilemma/Results.html`:

```html+jinja
{% extends "Base.html" %}

{% block title %}
Results
{% endblock title %}

{% block main %}

{% if player.cooperate %}
<p>You cooperated.</p>
{% else %}
<p>You did not cooperate.</p>
{% endif %}

{% if player.other_in_group.cooperate %}
<p>The other participant cooperated.</p>
{% else %}
<p>The other participant did not cooperate.</p>
{% endif %}

<p>Your payoff is <b>{{ player.payoff | fmtnum(pre="$", places=2) }}</b>.</p>

{% endblock main %}
```

`player.other_in_group` works in templates, too.

## 6. Run the experiment

Start the server with a ready-to-use room for your config:

```console
uv run uproot start my_dilemma
```

uproot prints a room link. Open it in two browser tabs: each tab joins as a new participant. Both participants read the instructions and then wait on the grouping page until the other arrives. After making their choices, they wait on the sync page until both have decided. Then they see their results.

!!! tip "Sessions for real experiments"
    `uproot start` is made for trying out an experiment. For real experiments, you usually create sessions in the [admin interface](https://uproot.science/running/admin.md#creating-sessions) instead.

## 7. Test with simulated participants

Clicking through two tabs gets tedious as your experiment grows. `simulate.js` can click for you. Open `my_dilemma/simulate.js` and replace the `uproot.simulate.on(...)` block below the comment with:

```javascript
uproot.simulate.on("my_dilemma/Instructions", (sim) => {
    sim.submit();
});

uproot.simulate.on("my_dilemma/Dilemma", (sim) => {
    sim.choose("cooperate", sim.random(["True", "False"])).submit();
});
```

The first handler submits the instructions page. The second one picks “Yes” or “No” at random and submits the decision page. Start uproot with `--simulate` to switch them on:

```console
uv run uproot start my_dilemma --simulate
```

Open the room link in two tabs again and watch both participants play by themselves. See [App testing](https://uproot.science/running/admin.md#app-testing) for all the helpers that `simulate.js` offers.

## The complete code

Below the comment at the top, `my_dilemma/__init__.py` now reads:

```python
from uproot.fields import *
from uproot.smithereens import *

DESCRIPTION = "Prisoner's dilemma"
SUGGESTED_MULTIPLE = 2

class C:
    PAYOFF_MATRIX = {
        # (you cooperate, other cooperates): your payoff
        (True, True): 10,
        (True, False): 0,
        (False, True): 15,
        (False, False): 3,
    }

class Instructions(Page):
    pass

class GroupPlease(GroupCreatingWait):
    group_size = 2

class Dilemma(Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )

class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group: GroupType) -> None:
        for player in group.players:
            player.payoff = C.PAYOFF_MATRIX[
                player.cooperate,
                player.other_in_group.cooperate,
            ]

class Results(Page):
    pass

page_order = [
    Instructions,
    GroupPlease,
    Dilemma,
    Sync,
    Results,
]
```

Your app now matches the `prisoners_dilemma` sample app in your project, apart from its name.

[See the full prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

## What’s next?

- **[Project structure](https://uproot.science/getting-started/project-structure.md):** Understand the files in your project.
- **[Pages and templates](https://uproot.science/building/pages.md):** Learn about page lifecycle methods.
- **[Collecting data with forms](https://uproot.science/building/forms.md):** Explore all available field types.
- **[Grouping participants](https://uproot.science/multiplayer/groups.md):** More on multiplayer experiments.
- **[Variations on this game](https://github.com/mrpg/uproot-examples):** `prisoners_dilemma_repeated`, `prisoners_dilemma_indefinite`, `prisoners_dilemma_chat`, and `prisoners_dilemma_fancy` build on the app you just wrote.

---

Source: https://uproot.science/getting-started/project-structure.md

# Project structure

An uproot project is a Python package with a specific layout. Here is what a typical project looks like.

## Directory layout

```
my_project/
├── main.py                  # Entry point and configuration
├── pyproject.toml           # Python dependencies
├── requirements.txt         # Alternative dependency file
├── .env                     # Local environment settings
├── Procfile                 # For cloud deployment (Heroku)
├── uproot_license.txt       # uproot’s LGPL license
├── _static/                 # Static files shared by all apps (optional)
├── my_app/
│   ├── __init__.py          # App logic: pages, fields, callbacks
│   ├── Welcome.html         # Template for Welcome page
│   ├── Decision.html        # Template for Decision page
│   ├── Results.html         # Template for Results page
│   ├── simulate.js          # Automated page interactions for testing (optional)
│   └── _static/             # App-specific static files (optional)
│       └── diagram.png
└── another_app/
    ├── __init__.py
    └── ...
```

## The `main.py` file

The entry point configures your experiment and starts the server:

```python
import uproot.deployment as upd
from uproot.cli import cli
from uproot.server import load_config, uproot_server

load_config(uproot_server, config="my_experiment", apps=["my_app"])

upd.ADMINS["admin"] = upd.auto_login()  # Secret login link, or password from the environment

upd.LANGUAGE = "en"  # Built-in: "de", "en", "es", "fr", "ja"

if __name__ == "__main__":
    cli()
```

### Configs

Each `load_config` call registers a **config**: a named experiment configuration that specifies which apps to run and in what order:

```python
# A single-app config
load_config(uproot_server, config="survey", apps=["survey"])

# A multi-app config: participants go through both apps in sequence
load_config(uproot_server, config="full_experiment", apps=["instructions", "game", "survey"])
```

When you create a session in the admin, you select a config. The session runs all listed apps in order.

During development, the fastest way to try a config is [`uproot start`](https://uproot.science/reference/cli.md#uproot-start): `uv run uproot start my_experiment` starts the server and prints a participant link for that config, with no admin steps needed.

You can optionally pass a `settings` dictionary to `load_config` that provides default session settings:

```python
load_config(
    uproot_server,
    config="my_experiment",
    apps=["my_app"],
    settings={"n_rounds": 5, "show_feedback": True},
)
```

In addition, uproot registers every app on its own, under the app’s name with a tilde (such as `~my_app`). The admin lists these under “Apps.” They are handy for testing one app in isolation, but they have **no settings**. If your app reads `session.settings`, give defaults, as in `session.settings.get("n_rounds", 5)`, or fail with a clear message in `new_session` when a required setting is missing.

### Admin accounts

Keep the default setup to sign in with a secret login link or a password:

```python
upd.ADMINS["admin"] = upd.auto_login()
```

When `UPROOT_ADMIN_PASSWORD` is unset or empty and `admin` is the only admin account, uproot prints a login link containing a cryptographically random secret token. You can use this locally or in production; keep the link private and use HTTPS on public servers. For password login, set `UPROOT_ADMIN_PASSWORD` through your hosting provider’s secret settings or a `.env` file kept out of Git. See [Admin authentication](https://uproot.science/running/admin.md#authentication) for setup instructions and multiple accounts.

### API keys

```python
# Register a server-side REST API token
upd.API_KEYS.add("your-api-key")
```

`UPROOT_API_KEY` is read by the `uproot api` client. It does not register a
server token by itself; add the token to `upd.API_KEYS` in the project.

### Default rooms

```python
from uproot.rooms import room

upd.DEFAULT_ROOMS.append(
    room("my_room", config="my_experiment", labels=["A", "B", "C"])
)
```

Rooms defined this way are created automatically when the server starts.

## App module

Each app is a Python package (a directory with `__init__.py`). The `__init__.py` defines the experiment logic.

### Required

- **`page_order`:** list of page classes (and [SmoothOperators](https://uproot.science/building/operators.md)) that define the participant flow:

```python
page_order = [Welcome, Decision, Results]
```

### Optional module-level attributes

| Attribute | Purpose |
|-----------|---------|
| `DESCRIPTION` | Human-readable description shown in admin |
| `SUGGESTED_MULTIPLE`{ .text-nowrap } | Hint for session creation (e.g., `2` for pair experiments) |
| `LANDING_PAGE` | If `True`, inserts a landing page before the app’s pages. Override it with `LandingPage.html` in the app directory, or add extra text with `LandingPageInfo.html` |
| `C` | Constants class, available in templates as `C`. Set `C.__export__` to a list of names (or `...`) to copy those constants into JavaScript as `window.C` |

### Optional callbacks

| Callback | When it runs |
|----------|-------------|
| `new_session(session)`{ .text-nowrap } | Once when session initializes |
| `new_player(player)`{ .text-nowrap } | Once per player when they join |
| `restart()` | On server restart (can be async) |
| `digest(session)` | Returns data for the admin digest view (pair with `AdminDigest.html`) |
| `pipeline(session, data)` | Admin-runnable job; return a list of dicts for a downloadable table. `data` is the posted JSON or `None` |
| `language(player)` | Returns ISO 639 language code for the player |
| `api(request, session)` | Authenticated HTTP endpoint at `/api/{app}/{sname}/` |
| `api2(request, session, player=None)` | Participant HTTP endpoint at `/api2/{app}/{sname}/`; public by default, with optional participant authentication |

See [Storing and accessing data](https://uproot.science/building/data.md) for details on `new_session` and `new_player`. See [App HTTP APIs](https://uproot.science/reference/api.md#app-http-apis) for the advanced `api2` authentication behavior.

### Page classes

Pages are defined as classes that inherit from `Page`, `NoshowPage`, `GroupCreatingWait`, or `SynchronizingWait`:

```python
class Welcome(Page):
    pass

class Calculate(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        player.score = player.correct * 10
```

See [Pages and templates](https://uproot.science/building/pages.md) for details.

### Templates

Each displayed page needs a corresponding HTML or [Markdown](https://uproot.science/building/pages.md#markdown-pages) template in the same app directory. By default, uproot first looks for an `.html` file matching the class name, then for `.md`:

```
my_app/
├── __init__.py      # class Welcome(Page)
└── Welcome.html     # Template for Welcome (or Welcome.md)
```

HTML templates extend a base layout:

```html+jinja
{% extends "Base.html" %}

{% block title %}Welcome{% endblock title %}

{% block main %}
<h1>Welcome to the experiment</h1>
{% endblock main %}
```

### Using `PlayerContext`

Define a `Context` class for computed values accessible across all templates:

```python
class Context(PlayerContext):
    @property
    def total_earnings(self):
        return self.player.payoff * C.EXCHANGE_RATE
```

Available in templates as `player.context.total_earnings`. See [The PlayerContext class](https://uproot.science/building/pages.md#the-playercontext-class) for Python access and app lifecycle details.

## Database

uproot uses SQLite by default. The database file `uproot.sqlite3` is created automatically in your project directory when the server starts. No configuration needed.

SQLite works well in production too; uproot is optimized for it. PostgreSQL is available as an alternative but is never required. To use it, add PostgreSQL support to your project with `uv add 'uproot-science[pg]<1'`. See [Deployment](https://uproot.science/running/deployment.md) for details.

### Why uproot loves SQLite

SQLite is fast, exceptionally well tested, and stable. It needs no separate database server to install or manage, so you can run a real experiment with the same simple setup you used to build it. It just works, even if you have massive experiments that are highly simultaneous. For a look at the care behind that reliability, watch SQLite creator _Dr_ D. Richard Hipp’s talk, [“Reliability Lessons From SQLite”](https://www.youtube.com/watch?v=V_qzqY1bb7I).

## Environment variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `UPROOT_DATABASE` | `sqlite3` | Database driver (`sqlite3`, `memory`, `postgresql`) |
| `UPROOT_SQLITE3` | `uproot.sqlite3`{ .text-nowrap } | SQLite file path |
| `UPROOT_POSTGRESQL` | — | PostgreSQL connection URL |
| `UPROOT_ORIGIN` | — | Public server URL |
| `UPROOT_SUBDIRECTORY` | — | Subdirectory prefix for all routes |
| `UPROOT_API_KEY` | — | Bearer token used by the `uproot api` client |
| `UPROOT_ADMIN_PASSWORD`{ .text-nowrap } | — | Password used by `upd.auto_login()`; unset or empty enables auto-login for a sole `admin` account |
| `UPROOT_ALLOW_ENTER` | off | If `1`/`true`/`yes`/`on`, the Enter key submits participant forms |

Run `uproot deployment` to see the current values.

## What’s next?

- **[Pages and templates](https://uproot.science/building/pages.md):** How pages work.
- **[Collecting data with forms](https://uproot.science/building/forms.md):** Available field types.
- **[The admin interface](https://uproot.science/running/admin.md):** Managing your experiments.

---

Source: https://uproot.science/upgrade.md

# Upgrading

uproot projects pin a specific version so that a new release does not change the software used in your study. You can keep that reproducibility and still upgrade deliberately when you are ready.

This page assumes you have an existing project set up with [uv](https://uproot.science/getting-started/installation.md).

!!! warning "Check announcements as often as possible"
    In the [admin interface](https://uproot.science/running/admin.md#announcements), open **Status** (`/admin/status/`) and click **Check for important announcements**. Do this as often as possible—ideally every time you open the admin interface, and always before collecting data or upgrading.

    The check shows the recommended version and important notices, including security fixes and messages for your installed version. **uproot does not check automatically.**

    You can also [check from the terminal](https://uproot.science/reference/cli.md#uproot-announcements): `uv run uproot announcements`.

## Upgrade and keep an exact pin

Check [uproot-science on PyPI](https://pypi.org/project/uproot-science/) for the latest release. Stop your running server with ++ctrl+c++ in its terminal.

In your project directory—the folder containing `main.py` and `pyproject.toml`—run these commands. **Replace `X.Y.Z` with the release you want to use**; it is an example version here.

```console
uv add "uproot-science==X.Y.Z"
uv run uproot --version
```

The `==` is an **exact version pin**: it requires that particular version. Despite its name, [`uv add`](https://docs.astral.sh/uv/concepts/projects/dependencies/#changing-dependencies) also updates existing dependencies. It replaces the old pin in `pyproject.toml`, updates `uv.lock`, and installs the selected release and any required changes to other packages.

Restart with `uv run uproot run` and test your experiment before collecting data with the new version.

## Keep your study reproducible

The pin in `pyproject.toml` specifies your uproot version. The **lockfile**, `uv.lock`, records exact versions of the other Python packages your project needs, too. Keep both files with your experiment code, and archive the project version used for each dataset. If you use Git, commit both files together.

To recreate that software environment on another computer, use the saved project and run:

```console
uv sync --locked
```

This installs the recorded versions. `--locked` reports an error if the lockfile needs updating, so mismatched project files do not silently change your environment. Ordinary `uv sync` and `uv run` also reuse locked versions when they still satisfy your project’s requirements. See [uv’s lockfile documentation](https://docs.astral.sh/uv/concepts/projects/sync/).

## Editing the pin yourself

You can instead change the version after `==` in the `uproot-science` entry under `dependencies` in `pyproject.toml`, then run `uv sync`. Keep the other dependencies as they are.

Running `uv sync --upgrade-package uproot-science` alone respects the existing pin, so it cannot upgrade past that version.

If your [deployment](https://uproot.science/running/deployment.md) installs from `requirements.txt`, update its uproot pin to the same version too; `uv add` does not update that file.

---

Source: https://uproot.science/building/pages.md

# Pages and templates

Pages are the building blocks of uproot experiments. Each page represents a screen that participants see, for instance, instructions, questions, feedback, or results. Pages are defined as Python classes and rendered using HTML or Markdown templates.

## Defining a page

A page is a class that inherits from `Page`:

```python
class Welcome(Page):
    pass
```

This minimal page displays the template `Welcome.html` from your app directory. Every page needs a matching template: `Welcome.html`, or `Welcome.md` if you prefer Markdown (see [Markdown pages](#markdown-pages)).

### The `page_order` list

The `page_order` list defines which pages participants see and in what sequence:

```python
page_order = [
    Welcome,
    Instructions,
    Task,
    Results,
]
```

Participants progress through pages in order. You can use [SmoothOperators](https://uproot.science/building/operators.md) to randomize, repeat, or conditionally select pages.

`page_order` can also be a callable that takes `player` as a keyword argument, letting you build the sequence dynamically per participant:

```python
def page_order(player):
    pages = [Instructions, Task]
    if player.in_treatment:
        pages.append(TreatmentPage)
    pages.append(Results)
    return pages
```

Nested lists are flattened automatically, so you can compose page sequences from reusable building blocks, including SmoothOperators:

```python
def page_order(player):
    warmup = [Instructions, RandomOrder(PracticeA, PracticeB)]
    main_task = [Rounds(Decision, Feedback, n=5)]
    return [warmup, main_task, Results]
```

## Templates

Templates define what participants see. Most are HTML files; you can also write [Markdown pages](#markdown-pages). uproot uses [Jinja2](https://jinja.palletsprojects.com/) for templating.

### Template naming and location

By default, uproot looks for a template matching the page class name:

```
my_app/
├── __init__.py      # Contains class Welcome(Page)
└── Welcome.html     # Template for Welcome page (or Welcome.md)
```

To use a custom template path:

```python
class Welcome(Page):
    template = f"{__name__}/Page0.html"
```

Explicit template paths start at the project root, so include the app module name as shown.

### Basic template structure

A typical template extends the base layout and defines content:

```html+jinja
{% extends "Base.html" %}

{% block main %}
<h1>Welcome to the experiment</h1>
<p>Thank you for participating.</p>
{% endblock %}
```

The `Base.html` base template provides the form wrapper, navigation buttons, and styling. Your content goes in the `main` block.

Available blocks:

| Block | Position |
|-------|----------|
| `title` | Page title (shown in the browser tab and as the heading) |
| `head` | Extra content in `<head>` (CSS, meta tags) |
| `pre_main` | Before the main content, outside the Bootstrap container |
| `main` | Main page content (inside the container) |
| `main_full_width`{ .text-nowrap } | After `main`, full viewport width; use this for banners or charts that should break out of the container |
| `main2` | A second container section after `main_full_width` |
| `late` | Extra content at the end of `<body>` (scripts) |

There are also `main2_full_width`, `main3`, `late2`, `footer`, `header_start`, and `header_end` if you need more slots.

### Base template switches

The built-in templates also read a few optional Jinja variables. Set them near the top of a child template when you need to disable part of the default layout:

```html+jinja
{% extends "Base.html" %}
{% set buttons = False %}
{% set disable_connection_lost_modal = True %}
```

| Variable | Default | Effect when set to `True` |
|----------|---------|---------------------------|
| `buttons` | `True` | Set to `False` to hide the default Back/Next buttons |
| `disable_bootstrap` | `False` | Do not load uproot’s bundled Bootstrap CSS and JavaScript |
| `disable_uproot_fonts` | `False` | Do not load uproot’s bundled web fonts |
| `disable_tabular_numbers` | `False` | Do not load the tabular-number font stylesheet |
| `disable_terms` | `False` | Do not load the terms script |
| `disable_auto_start` | `False` | Do not run the default `uproot.init()` and WebSocket startup hook |
| `disable_connection_lost_modal`{ .text-nowrap } | `False` | Do not show or enable the connection-lost modal |

For admin templates that extend the built-in admin layout, `disable_navigation = True` hides the admin navigation bar.

### Adding a form

To collect data, include form fields in your template:

```html+jinja
{% extends "Base.html" %}

{% block main %}
<h1>Your decision</h1>

{{ form.amount.label }}
{{ form.amount }}

{% endblock %}
```

See [Collecting data with forms](https://uproot.science/building/forms.md) for details on defining form fields.

## Passing data to templates

### The templatevars method

Use the `templatevars` method to pass data from Python to your template:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            earnings=player.payoff,
            partner_choice=player.other_in_group.choice,
        )
```

Then use these variables in your template:

```html+jinja
{% extends "Base.html" %}

{% block main %}
<h1>Results</h1>
<p>You earned {{ earnings }} points.</p>
<p>Your partner chose: {{ partner_choice }}</p>
{% endblock %}
```

The `templatevars` method receives `page` (the page class) and `player` (the current participant’s data).

### The PlayerContext class

Use `PlayerContext` when you want to compute the same value on several pages. Define a `Context` class in your app, then add a property for each value:

```python
class Context(PlayerContext):
    @property
    def earnings(self):
        return self.player.payoff

    @property
    def partner_choice(self):
        return self.player.other_in_group.choice
```

Access these in templates:

```html+jinja
<p>You earned {{ player.context.earnings }} points.</p>
<p>Your partner chose: {{ player.context.partner_choice }}</p>
```

You can use the same properties in Python page methods:

```python
class Results(Page):
    @classmethod
    def show(page, player):
        return player.context.earnings > 0
```

The `self.player` attribute gives the `Context` object access to the current participant. Store lasting data on `self.player`, `self.player.group`, or `self.player.session`—not on the `Context` object itself. Each access to `player.context` creates a lightweight `Context` object.

#### How uproot selects the Context class

`player.context` refers to the `Context` class of the app that the participant is currently using. uproot reads `player.app`, finds that app’s `Context` class, and constructs it with the player. If the current app does not define a `Context` class, `player.context` is `None`.

While the participant is inside an app that defines `Context`, these two expressions therefore produce the same result:

```python
player.context.earnings
Context(player).earnings
```

The second expression does not need to discover an app. `Context` is an ordinary Python name that refers directly to the class defined in the current app module. The constructor inherited from `PlayerContext` stores `player` as `self.player`.

Usually, prefer `player.context`: it is concise and automatically follows the participant’s current app. Use `Context(player)` when you are outside page execution (see the warning below) or when code must target a specific app’s `Context` class.

!!! warning "Do not use `player.context` outside page execution"
    `player.context` relies on `player.app`, which tracks whichever app the participant is currently in. Outside page methods and templates, that value is unreliable: it may be `None` (before the first app), or it may point to a different app entirely.

    Do not use `player.context` in `page_order(player)`, `new_player(player)`, `digest(session)`, `pipeline(session, data)`, or any other app-level function. In those places, construct the context explicitly:

    ```python
    context = Context(player)
    value = context.earnings
    ```

[See PlayerContext in the public_goods_game example](https://github.com/mrpg/uproot-examples/tree/main/public_goods_game) · [trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game) · [bertrand example](https://github.com/mrpg/uproot-examples/tree/main/bertrand)

## Built-in template variables

Every template has access to these variables:

| Variable | Description |
|----------|-------------|
| `player` | The current participant’s data |
| `form` | The form instance (if the page has fields) |
| `page` | The page class |
| `C` | Constants defined in your app’s `C` class |
| `session` | The current session |
| `_("text")` | Translation function for internationalization |

### Accessing player data

```html+jinja
<p>Your name: {{ player.participant_name }}</p>
<p>Current round: {{ player.round }}</p>
```

### Using constants

Define constants in your app:

```python
class C:
    ENDOWMENT = 100
    EXCHANGE_RATE = 0.10
```

Use them in templates:

```html+jinja
<p>You start with {{ C.ENDOWMENT }} points.</p>
<p>Each point is worth ${{ C.EXCHANGE_RATE }}.</p>
```

To also use constants in JavaScript (and [Alpine.js](https://uproot.science/building/alpinejs.md)), list their names on `C.__export__`:

```python
class C:
    WORD_LENGTH = 5
    DURATION = 120
    __export__ = ["WORD_LENGTH", "DURATION"]
```

uproot copies those values to `window.C`, so `C.WORD_LENGTH` works in scripts. Set `__export__ = ...` (Ellipsis) to export every non-dunder attribute.

[See C.__export__ in the encryption_task example](https://github.com/mrpg/uproot-examples/tree/main/encryption_task) · [emoji_sort example](https://github.com/mrpg/uproot-examples/tree/main/emoji_sort)

## Static files

### App-specific static files

Place static files (images, CSS, JavaScript) in an `_static/` folder within your app:

```
my_app/
├── __init__.py
├── Welcome.html
└── _static/
    ├── diagram.png
    └── custom.css
```

Reference them using `appstatic()`:

```html+jinja
<img src="{{ appstatic('diagram.png') }}" alt="Diagram">
<link rel="stylesheet" href="{{ appstatic('custom.css') }}">
```

### Project-wide static files

For files shared across apps, put them in `_static/` at the project root and use `projectstatic()`:

```html+jinja
<script src="{{ projectstatic('shared.js') }}"></script>
```

To inject HTML into every page, add `ProjectHead.html` (inside `<head>`) or `ProjectBody.html` (inside `<body>`) at the project root.

## Markdown pages

If `Welcome.html` is missing, uproot looks for `Welcome.md` instead. Put exactly one level-one heading (`#`) in the file; uproot uses it as the page title and removes it from the main content.

You can still use Jinja. That is, fields, `player`, `C`, and the rest work as usual:

```markdown
# Welcome

Thank you for participating. You start with {{ C.ENDOWMENT }} points.

{{ field(form.consent) }}
```

If the Markdown file name does not match the page class, set its project-root-relative path:

```python
class Welcome(Page):
    template = f"{__name__}/Instructions.md"
```

[See the anchoring_markdown example](https://github.com/mrpg/uproot-examples/tree/main/anchoring_markdown)

## Translations

uproot’s built-in interface strings ship in English (`en`), French (`fr`), German (`de`), Spanish (`es`), and Japanese (`ja`). This includes buttons, wait pages, and error messages of forms. Set the default in `main.py`:

```python
upd.LANGUAGE = "de"
```

To choose a language per participant, define `language(player)` in the app. It should return an ISO 639 code:

```python
def language(player):
    return player.session.settings.get("language", "en")
```

In templates, `_("text")` looks up a translation for the current language. Wrap phrases in `{% translate %}...{% endtranslate %}` when you want the same lookup with whitespace collapsed. In JavaScript, `_("text")` works the same way.

To add your own phrases, put YAML files in a directory (one file per language, or one file with all languages) and load them at startup:

```python
import uproot.i18n as i18n

i18n.load("locales/")
```

A file per language is named after its language code, such as `locales/fr.yml`. Each entry maps the phrase as written in your template to its translation:

```yaml
? "Please answer the following questions:"
: "Veuillez répondre aux questions suivantes :"

? "Click “Next” when you are ready."
: "Cliquez sur « Suivant » lorsque vous êtes prêt(e)."
```

Your files can also translate uproot’s built-in strings, such as `Next` or `Please wait`. This lets you run studies in a language that uproot does not ship. The built-in strings are listed in [uproot’s `en.yml`](https://github.com/mrpg/uproot/blob/main/src/uproot/default/locales/en.yml).

!!! warning "Keys must match exactly"
    uproot finds a translation only if the key matches the phrase in your template exactly, apart from whitespace. Straight quotes (`"Next"`) and typographical quotes (`“Next”`) are different keys. If no translation matches, uproot silently shows the phrase itself, usually in English.

    To find such problems, run [`uproot check-translations`](https://uproot.science/reference/cli.md#uproot-check-translations) in your project directory:

    ```console
    uproot check-translations
    ```

    It lists every phrase that is missing for a language that has a YAML file in your project, including the labels and choices of form fields. It also points out keys that differ only in quotes or spacing.

## Conditional page display

Use the `show` method to conditionally display pages:

```python
class BonusRound(Page):
    @classmethod
    def show(page, player):
        return player.score >= 80  # Only show if score is high enough
```

Pages where `show` returns `False` are skipped automatically.

### Role-based pages

A common pattern for multiplayer experiments:

```python
class ProposerDecision(Page):
    @classmethod
    def show(page, player):
        return player.role == "proposer"

class ResponderDecision(Page):
    @classmethod
    def show(page, player):
        return player.role == "responder"
```

## Allowing back navigation

By default, participants can only move forward. To allow going back:

```python
class Survey(Page):
    allow_back = True
```

This adds a “Back” button that lets participants revisit and change previous answers.

Set `keep_values = True` to pre-fill the form from values already stored on the player. This is useful when someone returns to a page and should see their previous answers.

!!! note
    Going back re-displays the page but does not undo any data that was
    already saved. Submission hooks like `after_once` do not run again.

## `NoshowPage` for logic-only pages

Sometimes you need to run code without displaying anything to participants. Use `NoshowPage`:

```python
class CalculatePayoffs(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        player.payoff = player.correct_answers * 10
```

`NoshowPage` runs its lifecycle methods but never renders a template. Use it for:

- Calculating scores or payoffs
- Initializing player data
- Setting up randomization

[See NoshowPage in the big5 example](https://github.com/mrpg/uproot-examples/tree/main/big5)

## Page lifecycle methods

Pages have several methods that run at different points:

| Method | When it runs |
|--------|--------------|
| `show` | Before displaying; return `False` to skip the page |
| `early` | Earliest hook when entering the page; has the HTTP request |
| `before_always_once`{ .text-nowrap } | Once when this page position is reached |
| `before_once` | Once per player, before first display |
| `templatevars` | Before rendering; return template variables |
| `after_once` | Once per player, after first submission |
| `after_always_once` | Once after this page position is submitted |

### Example: one-time initialization

```python
class Task(Page):
    @classmethod
    def before_once(page, player):
        # Runs once when the player first sees this page
        player.start_time = time()
```

### Example: cleanup after submission

```python
class Task(Page):
    @classmethod
    def after_always_once(page, player):
        # Runs after this page position is submitted
        player.attempts += 1
```

See [Page methods reference](https://uproot.science/reference/page-methods.md) for the complete list.

## JavaScript variables

To pass data to JavaScript, use the `jsvars` method:

```python
class TradingGame(Page):
    @classmethod
    def jsvars(page, player):
        return dict(
            initial_price=player.price,
            max_trades=C.MAX_TRADES,
        )
```

Access these in your template’s JavaScript:

```html+jinja
<script>
const price = uproot.vars.initial_price;
const maxTrades = uproot.vars.max_trades;
</script>
```

## Complete example

Here is a complete page with context, conditional display, and form handling:

```python
class Offer(Page):
    allow_back = True

    fields = dict(
        amount=IntegerField(
            label="How much do you offer?",
            min=0,
            max=100,
        ),
    )

    @classmethod
    def show(page, player):
        return player.role == "proposer"

    @classmethod
    def templatevars(page, player):
        return dict(
            endowment=C.ENDOWMENT,
            partner=player.other_in_group.name,
        )

    @classmethod
    def after_once(page, player):
        player.offer_made = True
```

```html+jinja
{% extends "Base.html" %}

{% block main %}
<h1>Make an offer</h1>

<p>You have {{ endowment }} points to split with {{ partner }}.</p>

{{ form.amount.label }}
{{ form.amount }}

{% endblock %}
```

[See complete examples in uproot-examples](https://github.com/mrpg/uproot-examples)

---

Source: https://uproot.science/building/forms.md

# Collecting data with forms

Forms are how you collect responses from participants. Define form fields on your page, and uproot handles rendering, validation, and storage automatically.

## The fields dictionary

Define fields as a dictionary on your page class:

```python
class Survey(Page):
    fields = dict(
        age=IntegerField(label="How old are you?", min=18, max=100),
        feedback=StringField(label="Any comments?", optional=True),
    )
```

Field names become attributes on the player object. After submission, access the data as `player.age` and `player.feedback`.

## Available field types

### Text fields

**`StringField`:** Single-line text input

```python
participant_name=StringField(label="Your name")
```

**`TextAreaField`:** Multi-line text input

```python
comments=TextAreaField(label="Additional comments", optional=True)
```

**`EmailField`:** HTML email input

```python
email=EmailField(label="Email address")
```

### Numeric fields

**`IntegerField`:** Whole numbers

```python
age=IntegerField(label="Age", min=0, max=120)
```

**`DecimalField`:** Decimal numbers (use this for money)

```python
amount=DecimalField(label="Amount to contribute", min=0.0, max=100.0)
```

**`FloatField`:** Floating-point numbers (same options as `DecimalField`, stored as `float`)

```python
reaction_time=FloatField(label="Reaction time (seconds)", min=0.0)
```

### Selection fields

**`RadioField`:** Choose one option with radio buttons

```python
choice=RadioField(
    label="Do you agree?",
    choices=[(True, "Yes"), (False, "No")],
)
```

**`SelectField`:** Dropdown selection

```python
country=SelectField(
    label="Country",
    choices=[
        ("us", "United States"),
        ("uk", "United Kingdom"),
        ("de", "Germany"),
    ],
)
```

The dropdown starts with an empty option (“—”). So a participant who skips the question does not submit the first country by accident. If the field is required, the empty option is rejected with an error message. If it is optional, the field is saved as `None`.

**`BooleanField`:** Checkbox

```python
consent=BooleanField(label="I agree to participate")
```

**`BoundedChoiceField`:** Multi-select checkboxes with optional min/max counts

```python
topics=BoundedChoiceField(
    label="Select your preferred topics",
    choices=["Economics", "Psychology", "Sociology"],
    min=1,
    max=3,
)
```

### Scales and sliders

**`LikertField`:** Rating scale with labeled endpoints

```python
satisfaction=LikertField(
    label="How satisfied are you?",
    label_min="Very unsatisfied",
    label_max="Very satisfied",
    min=1,
    max=7,
)
```

**`DecimalRangeField`:** Slider input

```python
confidence=DecimalRangeField(
    label="Confidence level",
    min=0,
    max=100,
    step=1,
)
```

To render several Likert items as one matrix (shared scale, one row per question)

```html+jinja
{{ likert_matrix(form.item1, form.item2, form.item3) }}
{{ errors() }}
```

`likert_grid()` is the responsive version: below a width threshold (default 992px, or pass `breakpoint=`), each row becomes its own column of radio buttons.

Unlike `field()`, these two macros do not show validation errors themselves. Add `{{ errors() }}` as in the example so participants can see why a required response was rejected.

[See likert_matrix in the input_elements example](https://github.com/mrpg/uproot-examples/tree/main/input_elements)

### Other fields

**`DateField`:** Date picker

```python
birthdate=DateField(label="Date of birth")
```

**`FileField`:** File upload (see [File uploads](https://uproot.science/advanced/uploads.md))

```python
document=FileField(label="Upload your document")
```

**`IBANField**` and `**BICField`:** Bank identifiers (require the optional `iban` extra, installed with `uv add 'uproot-science[iban]<1'`); see [Form fields](https://uproot.science/reference/fields.md)

## Field options

Most fields support these common options:

| Option | Description | Default |
|--------|-------------|---------|
| `label` | Question text shown to participant | `""` |
| `optional` | Allow empty responses where supported | `False` |
| `description` | Help text below the field | `None` |
| `default` | Pre-filled value | `None` |

### Numeric constraints

`IntegerField` and `DecimalField` support `min` and `max`:

```python
donation=IntegerField(
    label="How much do you donate?",
    min=0,
    max=100,
    description="Enter a value between 0 and 100",
)
```

### Choice formats

For `RadioField` and `SelectField`, choices can be:

```python
# List of (value, label) tuples
choices=[(1, "Option A"), (2, "Option B")]

# Dictionary
choices={"a": "Option A", "b": "Option B"}

# Simple boolean (RadioField only)
choices=[(True, "Yes"), (False, "No")]
```

The value is what gets stored; the label is what participants see.

### Radio button layout

Control radio button arrangement with `layout`:

```python
# Vertical (default)
choice=RadioField(label="Pick one", choices=[...], layout="vertical")

# Horizontal
choice=RadioField(label="Pick one", choices=[...], layout="horizontal")
```

## Rendering fields in templates

Fields render automatically when you extend the base template:

```html+jinja
{% extends "Base.html" %}

{% block main %}
<h1>Survey</h1>
{{ fields() }}
{% endblock %}
```

To render fields individually with custom layout:

```html+jinja
{% block main %}
<div class="row">
    <div class="col-6">{{ field(form.age) }}</div>
    <div class="col-6">{{ field(form.income) }}</div>
</div>

{{ field(form.comments) }}
{% endblock %}
```

`field()` already shows that field’s validation errors. Use `{{ errors() }}` only if you render inputs yourself and still need the error messages.

## Form validation

### Built-in validation

Fields validate automatically based on their type and options:

- Required fields must have a value (unless `optional=True`)
- Numeric fields check `min` and `max` bounds
- Email fields use an email input element; add custom validation if you need strict server-side email checks

Invalid submissions show error messages and keep the participant on the page.

### Custom validation

Add a `validate` method for custom validation logic:

```python
class Allocation(Page):
    fields = dict(
        give_a=IntegerField(label="Give to A", min=0, max=100),
        give_b=IntegerField(label="Give to B", min=0, max=100),
    )

    @classmethod
    def validate(page, player, data):
        if data["give_a"] + data["give_b"] > 100:
            return "Total cannot exceed 100"
```

The `data` parameter contains submitted values as a dictionary. Return a string (or list of strings) if there is an error. Return nothing if validation passes.

### Async validation

The validate method can be async for database lookups or external checks:

```python
@classmethod
async def validate(page, player, data):
    if await username_taken(data["username"]):
        return "Username already exists"
```

## Dynamic fields

Generate fields based on player state using the `fields` method:

```python
class DynamicSurvey(Page):
    @classmethod
    def fields(page, player):
        choices = [
            (i, f"Option {i}")
            for i in range(1, player.num_options + 1)
        ]
        return dict(
            selection=SelectField(label="Choose:", choices=choices),
        )
```

This is useful for:

- Personalizing questions based on prior responses
- Showing different options per experimental condition
- Building adaptive surveys
- Setting field constraints based on other players’ choices (in multiplayer games)

[See dynamic fields in the trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game)

## Stealth fields

Stealth fields are processed separately instead of being saved automatically. Use them for file uploads or when you need custom handling logic.

### Defining stealth fields

Mark fields as stealth and provide a handler:

```python
class UploadPage(Page):
    fields = dict(
        participant_name=StringField(label="Your name"),
        photo=FileField(label="Upload a photo"),
    )

    stealth_fields = ["photo"]

    @classmethod
    async def handle_stealth_fields(page, player, data):
        photo = data["photo"]
        if photo is not None:
            content = await photo.read()
            player.photo_size = len(content)
            player.photo_name = photo.filename
```

The `participant_name` field saves automatically to `player.participant_name`.
`player.name` is reserved for uproot’s generated player name. The `photo` field
is available in the `data` dictionary passed to `handle_stealth_fields` instead.

### Validation in stealth handlers

Return error messages to reject the submission:

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    document = data["document"]
    if document is None:
        return "Please upload a document"

    content = await document.read()
    if len(content) > 5_000_000:
        return "File too large (max 5MB)"

    # Process the file...
    player.doc_size = len(content)
```

### Dynamic stealth fields

Use a method instead of a list for dynamic stealth field names:

```python
@classmethod
def stealth_fields(page, player):
    return ["response"] if player.condition == "special" else []
```

Stealth fields are ideal for sensitive data like payment information that should not be stored in the main database:

```python
class PaymentForm(Page):
    fields = dict(
        iban=IBANField(label="Your IBAN"),
        rating=DecimalRangeField(label="Rate this experiment", min=0, max=5),
    )

    stealth_fields = ["iban"]

    @classmethod
    async def handle_stealth_fields(page, player, data):
        iban = data["iban"]
        # Write to separate secure storage, send to payment processor, etc.
        print(f"Payment data for {player}: {iban}")
```

[See the payment_data example](https://github.com/mrpg/uproot-examples/tree/main/payment_data)

## Accessing submitted data

After form submission, field values are automatically saved to the player:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            their_age=player.age,           # From IntegerField
            their_choice=player.choice,     # From RadioField
        )
```

Values are stored with their proper types: integers stay integers, Booleans stay Booleans.

## Complete example

Here is a complete page with multiple field types and validation:

```python
class Questionnaire(Page):
    fields = dict(
        age=IntegerField(
            label="What is your age?",
            min=18,
            max=100,
        ),
        gender=RadioField(
            label="What is your gender?",
            choices=[
                ("m", "Male"),
                ("f", "Female"),
                ("o", "Other"),
                ("na", "Prefer not to say"),
            ],
        ),
        risk=LikertField(
            label="How willing are you to take risks?",
            label_min="Not at all willing",
            label_max="Very willing",
            min=0,
            max=10,
        ),
        income=DecimalField(
            label="Annual income (in thousands)",
            min=0,
            max=10000,
            optional=True,
            description="This field is optional",
        ),
    )

    @classmethod
    def validate(page, player, data):
        if data["age"] < 18:
            return "You must be at least 18 to participate"
```

[See forms in the quiz example](https://github.com/mrpg/uproot-examples/tree/main/quiz)

---

Source: https://uproot.science/building/data.md

# Storing and accessing data

uproot uses an **append-only log** for all data storage. Every change is permanently recorded with a timestamp, creating a complete audit trail of your experiment. This architecture ensures data integrity, enables temporal analysis, and makes your research fully reproducible.

## The append-only log

Unlike traditional databases that overwrite data, uproot appends every change to a permanent log. This means:

- **Complete history**: Every value a field ever held is recorded
- **Timestamps**: Each change includes when it happened
- **Audit trail**: The code location that made each change is tracked
- **No data loss**: Even deleted fields are preserved (as "tombstones")

### Why this matters for research

The append-only log provides:

1. **Reproducibility:** Analyze the exact sequence of participant decisions.
2. **Debugging:** Trace when and where unexpected values appeared.
3. **Temporal analysis:** Study how responses evolved over time.
4. **Data integrity:** No accidental overwrites or race conditions.

See [Exporting data](https://uproot.science/running/export.md) for more information about when this data is exported.

## Initialization callbacks

Two module-level functions let you set up initial data before participants see any pages.

### new_session

`new_session(session)` is called once when a session is initialized. Use it to set up session-wide data, such as shared configuration, models, or anything that should exist before any player arrives:

```python
def new_session(session):
    session.chat = chat.create(session)
    session.total_contributions = 0
```

### new_player

`new_player(player)` is called once per participant when they first join. Use it to set up per-player defaults:

```python
def new_player(player):
    player.payoff = 0
    player.my_chat = chat.create(player.session)
    chat.add_player(player.my_chat, player)
```

### Lazy execution

Both callbacks are **lazy**: they run when the first participant initializes, not when the session is created in the admin. `new_session` runs before `new_player` for the very first participant in a session.

!!! note
    Both `new_session` and `new_player` can be manually re-triggered from the admin session dashboard. This is useful for resetting state or re-running initialization after fixing a bug mid-experiment.

[See new_player and new_session in the chat example](https://github.com/mrpg/uproot-examples/tree/main/chat) · [conjoint example](https://github.com/mrpg/uproot-examples/tree/main/conjoint)

## Random numbers

Use `rng()` whenever you need randomness—whether for assigning treatments, shuffling, or drawing from a distribution. It returns an independent `random.Random` object seeded from the operating system. If later draws must continue the same random sequence, store the generator on the player; the data layer saves its state.

```python
def new_player(player):
    randomizer = rng()
    player.treatment = randomizer.choice(["A", "B"])
    player.rng = randomizer
```

Do not use the module-level functions in `random` for experiment outcomes. A generator returned by `rng()` can be saved and resumed, which makes a sequence of draws reproducible from its stored state.

[See the treatments example](https://github.com/mrpg/uproot-examples/tree/main/treatments)

## Player data

Store data on individual participants using simple attribute assignment:

```python
class Decision(Page):
    fields = dict(
        choice=RadioField(label="Your choice", choices=[(1, "A"), (2, "B")]),
    )

    @classmethod
    def after_once(page, player):
        # Form fields are saved automatically, but you can add computed fields:
        player.made_choice = True
        player.choice_time = time()
```

Read data back the same way:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            their_choice=player.choice,
            choice_time=player.choice_time,
        )
```

Form field values are saved automatically when participants submit. You can store any additional data you need.

!!! warning
    The names `along`, `fields`, `flush`, `get`, `history`, `refresh`, `strict`, and `within` are reserved for storage operations. Use a different name for your own data fields.

## Session data

Store data shared across all participants in a session using `player.session`:

```python
class Setup(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        if not hasattr(player.session, "initialized"):
            player.session.initialized = True
            player.session.start_time = time()
            player.session.total_contributions = 0
```

Access session data from any player:

```python
@classmethod
def templatevars(page, player):
    return dict(
        session_start=player.session.start_time,
        total=player.session.total_contributions,
    )
```

Session data is visible to all participants and persists for the duration of the session.

### Session settings

When a session is created in the admin interface, administrators can provide a JSON settings object. Access these settings from any page via `player.session.settings`:

```python
class Decision(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            n_rounds=player.session.settings.get("n_rounds", C.DEFAULT_ROUNDS),
        )
```

In templates:

```html+jinja
<p>This session has {{ player.session.settings.n_pairs }} pairs.</p>
```

`session.settings` is a read-only dict-like object that supports both attribute access (`settings.key`) and `.get(key, default)`.

Apps can replace the admin’s raw JSON settings editor with their own form, see [Custom settings forms](https://uproot.science/advanced/settings-forms.md).

[See the read_settings example](https://github.com/mrpg/uproot-examples/tree/main/read_settings) · [conjoint example](https://github.com/mrpg/uproot-examples/tree/main/conjoint)

## Group data

For multiplayer experiments, store data at the group level using `player.group`:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2

    @classmethod
    def after_grouping(page, group):
        group.round = 1
        group.total_payoff = 0
```

Access group data from any group member:

```python
@classmethod
def templatevars(page, player):
    return dict(
        current_round=player.group.round,
        group_total=player.group.total_payoff,
    )
```

## Working with mutable types

When mutating lists or dictionaries in-place, uproot needs to know the value changed. Inside standard page methods (`templatevars`, `after_once`, `after_always_once`, etc.), this is handled automatically. Outside of page methods—for example, in standalone helper functions or `@live` methods—use a context manager:

```python
with player as p:
    p.scores.append(100)
    p.responses["q1"] = "yes"
# Changes are saved when exiting the with block
```

Using a context manager is always safe, even when not strictly required.

## Supported data types

uproot supports all common Python types:

| Type | Example |
|------|---------|
| Numbers | `player.score = 100`, `player.rate = 0.75` |
| Strings | `player.nickname = "Alice"` |
| Booleans | `player.consented = True` |
| Lists | `player.choices = [1, 2, 3]` |
| Dictionaries | `player.responses = {"q1": "yes"}` |
| None | `player.partner = None` |
| Decimals | `player.payment = Decimal("10.50")` |
| Tuples | `player.coordinates = (10, 20)` |

Nested structures work too:

```python
player.trial_data = {
    "round": 1,
    "choices": [1, 2, 3],
    "timing": {"start": 100.5, "end": 105.2},
}
```

Several other types are also supported, such as `set` and `random.Random`. A full list of permitted types is available [here](https://github.com/mrpg/appendmuch/blob/main/src/appendmuch/codec.py).

## Accessing other players

### Virtual fields (preferred)

uproot provides virtual fields on `player` for convenient access to related players. These work in page methods and directly in templates:

```python
# Other player in a 2-person group
partner_choice = player.other_in_group.choice

# All other players in the group (excluding current player)
for p in player.others_in_group:
    total += p.contribution

# Other player in a 2-person session
partner = player.other_in_session

# All other players in the session (excluding current player)
for p in player.others_in_session:
    print(p.name, p.payoff)
```

The same virtual fields work on `group` and `session` objects:

```python
# All players in a group
for p in player.group.players:
    total += p.contribution

# All players in a session
for p in player.session.players:
    print(p.name, p.payoff)

# All groups in a session
for g in player.session.groups:
    print(g.name)

# Only groups created in this app (needed in multi-app configs)
for g in player.session.groups(app=__name__):
    print(g.name)
```

There is no separate function-form API for these relationships in the current
framework; use the virtual fields shown above.

### Accessing history (advanced)

View the complete history of a player’s data:

```python
history = player.history()
# Returns: {"choice": [Value(...), Value(...)], "payoff": [...], ...}
```

Each historical value includes:

- `time`: Unix timestamp of the change;
- `data`: the value that was stored;
- `context`: the file and line that made the change;
- `unavailable`: whether this represents a deletion of a field.

Use `player.fields()` when you only need the names of fields that are currently available:

```python
field_names = player.fields()
# Returns: ["choice", "payoff", ...]
```

## Quick reference

| Storage level | Access | Use for |
|---------------|--------|---------|
| `player.field` | Individual | Participant responses, computed values |
| `player.session.field`{ .text-nowrap } | Shared | Session config, aggregate statistics |
| `player.group.field` | Group only | Group state, shared resources |

| Pattern | When to use |
|---------|-------------|
| `player.x = value` | Storing immutable data (numbers, strings, bools) |
| `with player as p:` | Mutating lists or dicts outside page methods |
| `hasattr(player, "x")` | Checking if a field exists |
| `player.fields()` | Listing the currently available fields |
| `player.history()`{ .text-nowrap } | Accessing the complete audit trail |

[See data patterns in the examples](https://github.com/mrpg/uproot-examples)

---

Source: https://uproot.science/building/results.md

# Displaying results

Results pages show participants their outcomes, payoffs, and other players’ choices. uproot uses Jinja2 templates with full access to Python builtins, enabling calculations and logic directly in your templates.

## Basic results display

A results page usually needs no Python code of its own:

```python
class Results(Page):
    pass
```

Its template reads everything it needs from `player`, which every template can access. In a two-player game, `player.other_in_group` is the other member of the group:

```html+jinja
{% extends "Base.html" %}

{% block title %}
Results
{% endblock title %}

{% block main %}

{% if player.cooperate %}
<p>You cooperated.</p>
{% else %}
<p>You did not cooperate.</p>
{% endif %}

{% if player.other_in_group.cooperate %}
<p>The other participant cooperated.</p>
{% else %}
<p>The other participant did not cooperate.</p>
{% endif %}

<p>Your payoff is <b>{{ player.payoff | fmtnum(pre="$", places=2) }}</b>.</p>

{% endblock main %}
```

The `fmtnum` filter prints the payoff as dollars with two decimal places (see [Formatting numbers](#formatting-numbers)).

[See the prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

## Accessing other players’ data

### Two-player groups

Access the other player via the virtual field `player.other_in_group` directly in templates, or pass it explicitly via `templatevars`:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(other=player.other_in_group)
```

```html+jinja
<p>Your partner sent {{ other.amount }}.</p>
```

[See the trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game) · [ultimatum_game example](https://github.com/mrpg/uproot-examples/tree/main/ultimatum_game)

### Larger groups

Pass a list of other players:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(others=player.others_in_group)
```

```html+jinja
<p>Other guesses:
{% for other in others %}
    {{ other.guess }}{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
```

[See the beauty_contest example](https://github.com/mrpg/uproot-examples/tree/main/beauty_contest) · [minimum_effort_game example](https://github.com/mrpg/uproot-examples/tree/main/minimum_effort_game)

## Formatting numbers

### The `to` filter

Format decimal places with the `| to(n)` filter:

```html+jinja
<p>Your score: {{ player.score | to(1) }}</p>      <!-- 7.3 -->
<p>Amount: ${{ player.amount | to(2) }}</p>        <!-- $12.50 -->
<p>Percentage: {{ player.pct | to(0) }}%</p>       <!-- 85% -->
```

[See the big5 example](https://github.com/mrpg/uproot-examples/tree/main/big5) · [focal_point example](https://github.com/mrpg/uproot-examples/tree/main/focal_point)

### The `fmtnum` filter

For currency and units with prefix/suffix:

```html+jinja
{{ player.payoff | fmtnum(pre="$", places=2) }}           <!-- $10.50 -->
{{ player.payoff | fmtnum(post=" EUR", places=2) }}       <!-- 10.50 EUR -->
{{ player.change | fmtnum(pre="$", places=2) }}           <!-- −$5.00 (uses minus sign) -->
```

Large numbers use comma thousands separators by default. Use `sep` and `decsep`
to choose different thousands and decimal separators, or set `sep=""` to omit
the thousands separator:

```html+jinja
{{ player.payoff | fmtnum(pre="$", places=2) }}                  <!-- $1,234.50 -->
{{ player.payoff | fmtnum(sep=".", decsep=",", places=2) }}      <!-- 1.234,50 -->
{{ player.payoff | fmtnum(sep="", places=2) }}                   <!-- 1234.50 -->
```

`fmtnum` rounds half up, as on paper: 2.345 becomes 2.35 and 2.5 becomes 3 with `places=0`. It uses the number as written in decimal, so values such as 1.005 also round up.

The same function is available in JavaScript, for amounts that change while the page is open. It takes the same options and gives the same result:

```html+jinja
<p>Current bonus: <span id="bonus"></span></p>

<script>
document.getElementById("bonus").innerText =
    uproot.fmtnum(1234.5, { pre: "$", places: 2 });  // $1,234.50
</script>
```

## Calculations in templates

uproot passes all Python builtins to templates. Perform calculations directly:

```html+jinja
<!-- Arithmetic -->
<p>Total: {{ player.claim + other.claim }}</p>
<p>Tripled amount: {{ sent * 3 }}</p>
<p>Share: {{ player.contribution / total * 100 | to(1) }}%</p>

<!-- Comparisons -->
{% if player.claim + other.claim <= 100 %}
<p>The sum is $100 or less.</p>
{% else %}
<p>The sum exceeds $100.</p>
{% endif %}
```

[See the focal_point example](https://github.com/mrpg/uproot-examples/tree/main/focal_point)

### Using Python builtins

Call `sum()`, `max()`, `min()`, `len()`, `range()`, `enumerate()`, `zip()`, and other builtins:

```html+jinja
<!-- Sum contributions -->
<p>Group total: {{ sum(p.contribution for p in others) + player.contribution }}</p>

<!-- Find extremes -->
<p>Highest bid: {{ max(p.bid for p in others) }}</p>

<!-- Enumerate items -->
{% for i, item in enumerate(items) %}
<p>{{ i + 1 }}. {{ item }}</p>
{% endfor %}

<!-- Zip lists together -->
{% for option_a, option_b in zip(options_a, options_b) %}
<tr>
    <td>{{ option_a }}</td>
    <td>{{ option_b }}</td>
</tr>
{% endfor %}
```

[See the mpl example](https://github.com/mrpg/uproot-examples/tree/main/mpl)

## Conditional display

### Role-based results

Show different content based on player role:

```html+jinja
{% if player.trustor %}
<p>You sent <b>{{ sent }}</b>, which was tripled to {{ tripled }}.</p>
<p>The other player returned <b>{{ returned }}</b> to you.</p>
{% else %}
<p>The other player sent {{ sent }}, tripled to <b>{{ tripled }}</b>.</p>
<p>You returned <b>{{ returned }}</b>.</p>
{% endif %}
```

[See the trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game) · [dictator_game example](https://github.com/mrpg/uproot-examples/tree/main/dictator_game)

### Outcome-based messages

```html+jinja
{% if player.winner %}
<p><b>You won!</b></p>
{% else %}
<p>You did not win this round.</p>
{% endif %}
```

[See the beauty_contest example](https://github.com/mrpg/uproot-examples/tree/main/beauty_contest)

## History tables

### Using `player.along()`

For repeated games, iterate through all rounds with `player.along()`:

```html+jinja
<table class="table">
    <thead>
        <tr>
            <th>Round</th>
            <th>Your number</th>
        </tr>
    </thead>
    <tbody>
        {% for round, data in player.along("round") %}
        <tr>
            <td>{{ round }}</td>
            <td>{{ data.number }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
```

The `along("round")` method returns tuples of `(round_number, player_data_for_that_round)`. The data for each round is a live, read-only view.

You can narrow the data before iterating. This is useful when one player stores data for several apps:

```python
for round_number, round_data in player.within(app=__name__).along("round"):
    print(round_number, round_data.choice)
```

If your app has several [blocks](https://uproot.science/building/operators.md#blocks), each block has its own round 1, 2, …, so narrow by block as well. Otherwise, the rounds of all blocks get mixed together:

```python
for round_number, round_data in player.within(app=__name__, block="main").along("round"):
    print(round_number, round_data.choice)
```

[See the rounds example](https://github.com/mrpg/uproot-examples/tree/main/rounds)

### Using `player.within()`

Access a specific round’s data with `player.within(round=n)`:

```html+jinja
<table class="table">
    <thead>
        <tr>
            <th>Round</th>
            <th>You</th>
            <th>Partner</th>
        </tr>
    </thead>
    <tbody>
        {% for round in rounds_so_far %}
        <tr>
            <td>{{ round }}</td>
            <td>
                {% if player.within(round=round).cooperate %}
                Cooperated
                {% else %}
                Defected
                {% endif %}
            </td>
            <td>
                {% if other.within(round=round).cooperate %}
                Cooperated
                {% else %}
                Defected
                {% endif %}
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>
```

Pass `rounds_so_far` from `templatevars`:

```python
class Decision(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            other=player.other_in_group,
            rounds_so_far=range(1, player.round),
        )
```

[See the prisoners_dilemma_repeated example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_repeated)

By default, a view may carry forward a value that was stored before the requested context began. Pass `strict=True` when you only want fields written while that context held:

```python
round_data = player.within(round=3, strict=True)
choice = round_data.get("choice")

for round_number, round_data in player.along("round", strict=True):
    print(round_number, round_data.get("choice"))
```

## Accessing constants

The `C` class is available in templates:

```python
class C:
    ROUNDS = 5
    ENDOWMENT = 100
```

```html+jinja
<p>Round {{ player.round }} of {{ C.ROUNDS }}</p>
<p>You started with {{ C.ENDOWMENT }} points.</p>
```

## Available filters

| Filter | Purpose | Example |
|--------|---------|---------|
| `to(n)` | Format to n decimal places | `{{ x \| to(2) }}` → `3.14` |
| `fmtnum(pre, post, places, sep, decsep)` | Format with prefix/suffix and separators | `{{ x \| fmtnum(pre="$", sep=",") }}` |
| `tojson` | Convert to JSON | `{{ data \| tojson }}` |
| `repr` | Python repr | `{{ x \| repr }}` |

### `tojson` and `| safe`

`tojson` converts a Python value to a JSON string. Jinja2’s autoescaping then HTML-encodes the result. This is the right default: it keeps values safe in HTML attributes and element content.

**Most of the time, `tojson` alone is all you need.** Numbers, Booleans, and `None` produce JSON like `42`, `true`, or `null`—no characters that autoescaping would change. Even inside `<script>` blocks, autoescaping is a no-op for these types, so `| safe` is unnecessary:

```html+jinja
{# These all work without | safe, everywhere — including <script> blocks #}
{{ player.score | tojson }}       {# 42 #}
{{ player.is_buyer | tojson }}    {# true #}
{{ player.profit | tojson }}      {# null #}
```

`| safe` is **only** needed when you put a value that may contain strings into a `<script>` block, because autoescaping would turn `"` into `&#34;` and break JavaScript parsing:

```html+jinja
<script>
    {# results contains strings → must use | safe inside <script> #}
    let data = {{ results | tojson | safe }};
</script>
```

In HTML (attributes, element content), never add `| safe`. Autoescaping is what protects you:

```html+jinja
{# Safe: autoescaping prevents XSS even if player.response contains malicious strings #}
<div x-data="{ response: {{ player.response | tojson }} }">
```

!!! warning
    Only use `| safe` inside `<script>` blocks, and only when the value may contain strings. Adding `| safe` in HTML attributes or element content disables escaping and can introduce XSS vulnerabilities.

## Summary

| Feature | Purpose |
|---------|---------|
| `templatevars(page, player)` | Pass variables to template |
| `player.other_in_group` | Get partner in 2-player group |
| `player.others_in_group` | Get all other group members |
| `group.players` | Get all group members |
| `player.along("round")` | Iterate all rounds |
| `player.within(round=n)` | Access specific round data |
| `{{ expression }}` | Output values |
| `{% if %}...{% endif %}` | Conditional display |
| `{% for %}...{% endfor %}` | Loops |
| Python builtins | `sum()`, `max()`, `range()`, etc. |

---

Source: https://uproot.science/building/operators.md

# SmoothOperators

SmoothOperators let you define dynamic page sequences that adapt at runtime. Instead of a fixed list of pages, you can randomize order, repeat sections, or select between alternatives—all declaratively in your `page_order`.

## RandomOrder

`RandomOrder` shuffles pages into a random order for each participant. This is essential for counterbalancing in within-subjects designs.

```python
page_order = [
    Instructions,
    RandomOrder(
        TaskA,
        TaskB,
        TaskC,
    ),
    Results,
]
```

Each participant sees `Instructions` first, then the three tasks in a randomized order, then `Results`.

[See the randomize_pages example](https://github.com/mrpg/uproot-examples/tree/main/randomize_pages)

### Keeping pages together with Bracket

Sometimes you want to randomize the order of *groups* of pages while keeping each group’s internal order intact. Use `Bracket` to treat multiple pages as a single unit:

```python
page_order = [
    RandomOrder(
        Bracket(Intro1, Task1, Debrief1),  # These three stay together
        Bracket(Intro2, Task2, Debrief2),  # These three stay together
        Bracket(Intro3, Task3, Debrief3),  # These three stay together
    ),
]
```

Participants see all three blocks, but in random order. Within each block, pages appear in their defined sequence.

!!! warning "Use Bracket, not a list"
    SmoothOperators take only pages and other SmoothOperators. If you write a list such as `RandomOrder([Intro1, Task1], [Intro2, Task2])`, uproot stops with an error like this:

    ```
    TypeError: Constraint violation: RandomOrder() takes pages and SmoothOperators, not [<class 'myapp.Intro1'>, <class 'myapp.Task1'>]. Use Bracket() to keep several pages together.
    ```

    Write `Bracket(Intro1, Task1)` instead.

### Randomizing entire apps

You can randomize the order of entire sub-experiments by unpacking their `page_order` into brackets:

```python
from . import app1, app2, app3

page_order = [
    RandomOrder(
        Bracket(*app1.page_order),
        Bracket(*app2.page_order),
        Bracket(*app3.page_order),
    ),
]
```

[See the randomize_apps example](https://github.com/mrpg/uproot-examples/tree/main/randomize_apps)

## Rounds

`Rounds` repeats a sequence of pages a fixed number of times, automatically tracking the current round.

```python
page_order = [
    Instructions,
    Rounds(
        Decision,
        Feedback,
        n=5,  # Repeat 5 times
    ),
    FinalResults,
]
```

Participants see `Instructions`, then cycle through `Decision` → `Feedback` five times, then see `FinalResults`.

### Accessing the current round

Inside your pages, access `player.round` to know which round you are in (1-indexed):

```python
class Decision(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            round_number=player.round,
            rounds_remaining=5 - player.round,
        )
```

```html+jinja
<p>Round {{ round_number }} of 5</p>
```

### Several loops in one app

Most apps have a single `Rounds` (or `Repeat`), and you can skip this section. If one app takes participants through *several* loops—for example, practice rounds followed by paid rounds—give each loop a name with `block=`:

```python
page_order = [
    Instructions,
    Rounds(Decision, Feedback, n=2, block="practice"),
    Rounds(Decision, Feedback, n=10, block="main"),
    FinalResults,
]
```

Each block counts `player.round` from 1, and uproot stores the name of the current block in `player.block`. If an app has several loops and one of them has no block, `uproot run` stops right away with an error. See [Blocks](#blocks) below for the details.

### Dynamic round counts

The number of rounds can come from a constant:

```python
class C:
    NUM_ROUNDS = 10

page_order = [
    Rounds(Trial, n=C.NUM_ROUNDS),
]
```

Or be calculated dynamically:

```python
page_order = [
    Rounds(Response, n=len(C.ITEMS)),  # One round per item
]
```

`n` must be a whole number (an `int`) of 0 or more. A calculation that results in a decimal number, such as `n=len(C.ITEMS) / 2`, stops uproot with `ValueError: Constraint violation: n must be a non-negative integer`. Use `//` to divide without a remainder (`n=len(C.ITEMS) // 2`), or wrap the result in `int(...)`.

[See the rounds example](https://github.com/mrpg/uproot-examples/tree/main/rounds)

### Rounds with groups

`Rounds` works seamlessly with multiplayer experiments. Combine it with `SynchronizingWait` to create repeated group interactions:

```python
page_order = [
    GroupPlease,
    Rounds(
        Dilemma,
        Sync,
        Results,
        n=C.ROUNDS,
    ),
]
```

[See the prisoners_dilemma_repeated example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_repeated)

## Repeat

`Repeat` repeats a sequence of pages indefinitely until you tell it to stop. Control the repetition by setting `player.add_round` to `True` (continue) or `False` (stop).

```python
page_order = [
    Repeat(
        Trial,
        Feedback,
    ),
    Results,
]
```

### Controlling repetition with player.add_round

Set `player.add_round` in your page logic to control whether the sequence repeats:

```python
class Feedback(Page):
    @classmethod
    def after_once(page, player):
        if player.score >= 100:
            player.add_round = False  # Stop repeating, proceed to Results
        else:
            player.add_round = True   # Continue to next repetition
```

Like `Rounds`, `Repeat` automatically tracks `player.round` so you can display progress or use it in your logic. It also accepts the same optional `block=` argument (see [Blocks](#blocks)).

## Between

`Between` randomly selects exactly one option from multiple alternatives. This is perfect for between-subjects designs where each participant sees only one condition.

```python
page_order = [
    Instructions,
    Between(
        ConditionA,
        ConditionB,
        ConditionC,
    ),
    Results,
]
```

Each participant sees exactly one of the three condition pages.

### Selecting between page groups

Use `Bracket` to select between multi-page sequences:

```python
page_order = [
    Between(
        Bracket(TreatmentIntro, TreatmentTask, TreatmentDebrief),
        Bracket(ControlIntro, ControlTask, ControlDebrief),
    ),
]
```

Each participant experiences either the full treatment sequence or the full control sequence.

### Tracking which condition was shown

uproot records each selection in `player.between_showed`, which you can use in your pages and in your analysis. It is a list with one entry for each `Between` the participant went through. Each entry lists the pages of the selected option as `"app/PageName"`. In an app called `myapp`, a participant who gets the treatment sequence from the example above has:

```python
player.between_showed == [
    ["myapp/TreatmentIntro", "myapp/TreatmentTask", "myapp/TreatmentDebrief"],
]
```

To use the condition on a later page, look at the last entry:

```python
class Results(Page):
    @classmethod
    def templatevars(page, player):
        treated = "myapp/TreatmentTask" in player.between_showed[-1]

        return dict(treated=treated)
```

Two details matter when you nest operators:

- If the selected option contains another `Between`, that inner `Between` adds its own entry, and the outer entry leaves out the inner one’s pages. With `Between(Between(A, B), C)`, a participant who sees `B` has `[[], ["myapp/B"]]`.
- An option without pages, such as `Rounds(A, n=0)`, gets an empty entry (`[]`), so that every `Between` a participant went through has an entry.

## Combining operators

SmoothOperators can be nested to create sophisticated experimental designs.

### Randomized treatments with random task order

```python
page_order = [
    Instructions,
    Between(
        Bracket(TreatmentIntro, Treatment),  # Treatment condition
        Bracket(ControlIntro, Control),      # Control condition
    ),
    RandomOrder(
        Task1,
        Task2,
        Task3,
    ),
    Results,
]
```

### Repeated rounds with randomized elements

```python
page_order = [
    Rounds(
        RandomOrder(
            StimulusA,
            StimulusB,
        ),
        Response,
        n=10,
    ),
]
```

### Nested operators

You can nest operators to create more complex designs:

```python
page_order = [
    Instructions,
    RandomOrder(
        Between(VersionA, VersionB),  # Randomly select one version
        Bracket(Control1, Control2),  # Keep these two together
        StandaloneTask,
    ),
    Results,
]
```

Here, participants see three things in random order: one of two versions (selected via `Between`), a two-page control sequence (kept together via `Bracket`), and a standalone task.

## Blocks

Blocks are optional. You only need them when one app takes participants through more than one `Rounds` or `Repeat`.

The problem they solve: if an app runs two loops and both counted rounds the same way, your data would contain two different “round 1”s, and you could not tell which answer belongs to which. A block gives each loop a name. uproot stores that name in `player.block`, so the app, the block, and the round together identify every round.

### Naming blocks

Pass `block=` to `Rounds` or `Repeat`:

```python
page_order = [
    Rounds(Decision, n=2, block="practice"),
    Rounds(Decision, n=10, block="main"),
]
```

Participants see `Decision` twice with `player.block == "practice"` and `player.round` going 1, 2, then ten more times with `player.block == "main"` and `player.round` going from 1 to 10.

Block names must be valid [Python identifiers](https://docs.python.org/3/reference/lexical_analysis.html#identifiers): letters, digits, and underscores, not starting with a digit (e.g., `"main"` or `"part_2"`, but not `"part 2"`). A loop without `block=` has `player.block == None`.

### Returning to a block

Loops with the *same* block name share one counter. A participant who returns to a block continues where they left off:

```python
page_order = [
    Rounds(Step, n=2, block="red"),   # red rounds 1, 2
    Rounds(Step, n=1, block="blue"),  # blue round 1
    Repeat(Step, block="red"),        # red rounds 3, 4, …
]
```

Counters start afresh in every app, so different apps can reuse the same block names.

[See the blocks example](https://github.com/mrpg/uproot-examples/tree/main/blocks)

### When uproot asks for blocks

If participants can go through several `Rounds` or `Repeat` in one app and one of them has no block, `uproot run` stops before the server starts and prints an error like this:

```
ValueError: Constraint violation: App 'myapp' can take participants through several Rounds or Repeat, so each of them needs a block, e.g., block="main". Rounds(Decision) has none. Blocks with the same name continue counting player.round, other blocks start at 1.
```

!!! warning "Page orders defined as functions"
    If your `page_order` is a [function](https://uproot.science/building/pages.md#the-page_order-list) instead of a list, uproot cannot check it at startup. The server starts normally, and the check runs only when a participant’s page order is built, right after they join. That participant then sees an “Internal Server Error” page, and the error above appears in the server’s terminal output. Test such apps with a participant before you run a session.

The fix is to add `block=` to every outermost `Rounds` and `Repeat` in that app. A few cases do not count as several loops:

- Loops inside `Between` count once, since each participant sees only one alternative. `Between(Rounds(A, n=3), Rounds(B, n=5))` needs no blocks.
- `Rounds` with `n=0` never runs and is ignored.

`RandomOrder(Rounds(A, n=3), Rounds(B, n=3))`, on the other hand, runs both loops and therefore needs a block for each.

### Nested loops

A `Rounds` or `Repeat` placed *inside* another one belongs to the outer loop’s block and cannot name its own. `player.round` keeps counting through every iteration at every level, and `player.round_nested` lists the position at each level—`[2, 1]` means “outer round 2, inner round 1.”

### Leaving an app

When a participant moves on to the next app or reaches the end, uproot removes `player.round`, `player.block`, and `player.round_nested`. Reading `player.round` in a later app that has no loop of its own therefore raises an `AttributeError`. To look back at earlier rounds, use [`player.within()`](https://uproot.science/building/results.md#using-playerwithin), for example `player.within(app="game", block="main", round=3)`.

## Summary

| Operator | Purpose | Key parameter |
|----------|---------|---------------|
| `RandomOrder` | Shuffle pages randomly | Pages as arguments |
| `Bracket` | Group pages as a unit | Pages as arguments |
| `Rounds` | Repeat pages n times | `n` for count; optional [`block`](#blocks) |
| `Repeat` | Repeat until stopped | `player.add_round` to control; optional [`block`](#blocks) |
| `Between` | Select one alternative | Pages/brackets as arguments |

---

Source: https://uproot.science/building/live-methods.md

# Live methods

Live methods let JavaScript call Python functions in real-time without page reloads. Use them for dynamic interfaces, live validation, background processing, and any interaction that needs immediate server response.

## The @live decorator

Mark a page method with `@live` to make it callable from JavaScript:

```python
class Counter(Page):
    @live
    def increment(page, player):
        player.counter += 1
        return player.counter
```

Live methods:

- Receive `page` and `player` as the first two arguments
- Can accept additional typed parameters
- Can return data back to the caller
- Can be sync or async

[See the counter example](https://github.com/mrpg/uproot-examples/tree/main/counter)

## Calling from JavaScript

Use `uproot.invoke()` to call live methods:

```javascript
uproot.invoke("increment").then(count => {
    document.getElementById("counter").innerText = count;
});
```

Pass arguments after the method name:

```javascript
// Single argument
uproot.invoke("set_value", 42);

// Multiple arguments
uproot.invoke("save_response", "question1", "answer");

// With error handling
uproot.invoke("increment")
    .then(result => updateDisplay(result))
    .catch(error => showError("Operation failed"));
```

## Request-response pattern

Return data directly from the live method. The JavaScript Promise resolves with the returned value:

```python
class Quiz(Page):
    @live
    def check_answer(page, player, answer: str):
        correct = answer.lower() == "paris"
        if correct:
            player.score += 1
        return {"correct": correct, "score": player.score}
```

```javascript
uproot.invoke("check_answer", userInput).then(result => {
    if (result.correct) {
        showSuccess("Correct! Score: " + result.score);
    } else {
        showError("Try again");
    }
});
```

## Type validation

Live method parameters are validated using Python type hints:

```python
@live
def place_bid(page, player, amount: float, item_id: int):
    # amount must be a float
    # item_id must be an integer
    player.bid = amount
    return {"placed": amount}
```

Invalid arguments raise an error that rejects the JavaScript Promise. Supported types include:

- `str`, `int`, `float`, `bool`
- `list`, `dict`
- `Optional[T]` for nullable values
- Custom types via Pydantic

## Connection lifecycle

Handle WebSocket connection events in JavaScript:

```javascript
uproot.onReady(() => {
    // WebSocket connected, safe to invoke methods
    uproot.invoke("get_initial_state").then(initDisplay);
});

uproot.onReconnect(() => {
    // Reconnected after disconnect, refresh state
    uproot.invoke("get_current_state").then(refreshDisplay);
});

uproot.onDisconnect(() => {
    // Connection lost, show warning
    showConnectionWarning();
});
```

## Example: dynamic counter

A counter that updates without page reload:

### Python

```python
def new_player(player):
    player.counter = 0

class Counter(Page):
    @live
    def increment(page, player):
        player.counter += 1
        return player.counter

    @live
    def reset(page, player):
        player.counter = 0
        return player.counter
```

### Template

```html+jinja
{% extends "Base.html" %}

{% block main %}
<p>Count: <span id="count">{{ player.counter }}</span></p>
<button onclick="increment()">+1</button>
<button onclick="reset()">Reset</button>

<script>
function increment() {
    uproot.invoke("increment").then(count => {
        document.getElementById("count").innerText = count;
    });
}

function reset() {
    uproot.invoke("reset").then(count => {
        document.getElementById("count").innerText = count;
    });
}
</script>
{% endblock main %}
```

[See the counter example](https://github.com/mrpg/uproot-examples/tree/main/counter) · [counter_alpine example](https://github.com/mrpg/uproot-examples/tree/main/counter_alpine) (with Alpine.js)

## Example: live form validation

Validate input as the user types:

```python
class Registration(Page):
    fields = dict(
        username=StringField(label="Username"),
    )

    @live
    def check_username(page, player, username: str):
        # Check if username is available
        taken = username.lower() in ["admin", "root", "system"]
        return {"available": not taken}
```

```javascript
document.getElementById("username").addEventListener("input", (e) => {
    uproot.invoke("check_username", e.target.value).then(result => {
        if (result.available) {
            showValid("Username available");
        } else {
            showError("Username taken");
        }
    });
});
```

## Example: fetching data

Load data dynamically without page reload:

```python
class Dashboard(Page):
    @live
    def get_stats(page, player):
        return {
            "total_responses": player.session.response_count,
            "average_score": player.session.average_score,
        }
```

```javascript
uproot.onReady(() => {
    // Fetch stats when page loads
    uproot.invoke("get_stats").then(stats => {
        document.getElementById("responses").innerText = stats.total_responses;
        document.getElementById("average").innerText = stats.average_score;
    });
});

// Refresh stats periodically
setInterval(() => {
    uproot.invoke("get_stats").then(updateDashboard);
}, 5000);
```

## Async operations

Live methods can be async for I/O operations. Keep reads and writes of `player`,
`player.session`, and other uproot storage outside the part of the method that
awaits whenever you can:

```python
@live
async def process_data(page, player, data: dict):
    # Do slow external work first.
    result = await some_async_function(data)

    # Then update uproot state without another await in between.
    player.processed = result
    return {"status": "complete"}
```

### Advanced concurrency note

Special care must be taken when you want to allow live methods to interleave with
`async`. Specifically:

Live method calls from the same browser page are not a transaction queue. If an
advanced `@live` method reaches an `await`, another live method call for the same
participant may run before the first one resumes. This matters only for methods
whose correctness depends on a check-then-act sequence across an `await`.

Avoid this pattern:

```python
@live
async def submit_once(page, player, payload: dict):
    if player.submitted:
        raise ValueError("Already submitted")

    result = await external_service(payload)

    player.result = result
    player.submitted = True
```

Prefer doing awaited work before entering the state-sensitive section, or keep
the state-sensitive section free of awaits:

```python
@live
async def submit_once(page, player, payload: dict):
    result = await external_service(payload)

    if player.submitted:
        raise ValueError("Already submitted")

    player.result = result
    player.submitted = True
```

For most live methods, including the examples in this guide, this is not a
concern because the method does not await while it is reading or updating uproot
state.

## Example: real-effort task

Live methods are ideal for real-effort tasks where participants interact repeatedly without page reloads:

```python
class Sumhunt(Page):
    timeout = 120

    @live
    def get_matrix(page, player):
        if player.matrix is None:
            player.matrix = generate_matrix(...)
        return player.matrix

    @live
    def propose_solution(page, player, solution: list[int]):
        if sum(solution) == TARGET and all(x in player.matrix for x in solution):
            player.solutions += 1
            player.matrix = generate_matrix(...)  # New puzzle
            return True
        return False
```

```javascript
// Fetch puzzle on page load
uproot.onReady(() => {
    uproot.invoke("get_matrix").then(updateDisplay);
});

// Submit answer and get next puzzle
uproot.invoke("propose_solution", selectedNumbers).then(correct => {
    if (correct) {
        uproot.invoke("get_matrix").then(updateDisplay);
    }
});
```

[See the sumhunt example](https://github.com/mrpg/uproot-examples/tree/main/sumhunt)

## Summary

| Feature | Purpose |
|---------|---------|
| `@live` decorator | Make method callable from JavaScript |
| `uproot.invoke("method", args)` | Call live method from JavaScript |
| Return value | Data returned to JavaScript Promise |
| Type hints | Automatic parameter validation |
| `uproot.onReady(fn)` | Run when WebSocket connects |
| `uproot.onReconnect(fn)` | Run after reconnection |
| `uproot.onDisconnect(fn)` | Run when connection lost |

For broadcasting updates to multiple participants, see [Real-time interactions](https://uproot.science/multiplayer/real-time.md).

---

Source: https://uproot.science/building/alpinejs.md

# Using Alpine.js

uproot includes [Alpine.js](https://alpinejs.dev/start-here), a lightweight JavaScript framework for adding interactivity to templates. It is already loaded—no setup required.

## Essentials

```html+jinja
<!-- Reactive state + display -->
<div x-data="{ count: 0 }">
    <span x-text="count"></span>
    <button @click="count++">+1</button>
</div>

<!-- Show/hide -->
<div x-data="{ open: false }">
    <button @click="open = !open">Toggle</button>
    <p x-show="open">Now you see me</p>
</div>

<!-- Two-way input binding -->
<div x-data="{ name: '' }">
    <input x-model="name">
    <p>Hello, <span x-text="name"></span></p>
</div>

<!-- Loop -->
<ul x-data="{ items: ['A', 'B', 'C'] }">
    <template x-for="item in items">
        <li x-text="item"></li>
    </template>
</ul>

<!-- Conditional classes -->
<button x-data="{ on: false }" @click="on = !on" :class="{ 'active': on }">
    Toggle
</button>

<!-- Disabled state -->
<div x-data="{ val: '' }">
    <input x-model="val">
    <button :disabled="val.length < 3">Submit</button>
</div>
```

## Global stores

For state shared across the page, use `Alpine.store()`:

```html+jinja
<script>
document.addEventListener("alpine:init", () => {
    Alpine.store("game", { score: 0, level: 1 });
});
</script>

<div x-data>
    <p>Score: <span x-text="$store.game.score"></span></p>
    <button @click="$store.game.score++">+1</button>
</div>
```

## Constants in JavaScript

If your app’s `C` class sets `__export__`, those constants are available as `C` in scripts and Alpine expressions (`window.C`). See [Using constants](https://uproot.science/building/pages.md#using-constants).

## With live methods

Combine Alpine stores with `uproot.invoke()` for reactive server communication:

```html+jinja
<script>
document.addEventListener("alpine:init", () => {
    Alpine.store("task", { data: null, answer: "" });

    uproot.onReady(() => {
        uproot.invoke("get_puzzle").then(data => {
            Alpine.store("task").data = data;
        });
    });
});

function submit() {
    const store = Alpine.store("task");
    uproot.invoke("check", store.answer).then(ok => {
        if (ok) uproot.invoke("get_puzzle").then(d => store.data = d);
    });
}
</script>

<div x-data>
    <p x-text="$store.task.data?.question"></p>
    <input x-model="$store.task.answer" @keydown.enter="submit()">
    <button @click="submit()">Submit</button>
</div>
```

[counter_alpine example](https://github.com/mrpg/uproot-examples/tree/main/counter_alpine) · [sumhunt example](https://github.com/mrpg/uproot-examples/tree/main/sumhunt) · [encryption_task example](https://github.com/mrpg/uproot-examples/tree/main/encryption_task)

## Quick reference

| Directive | Purpose | Example |
|-----------|---------|---------|
| `x-data` | Create reactive scope | `x-data="{ open: false }"` |
| `x-text` | Set text content | `x-text="message"` |
| `x-show` | Toggle visibility | `x-show="isVisible"` |
| `x-model` | Two-way bind input | `x-model="answer"` |
| `x-for` | Loop over array | `x-for="item in items"` |
| `@click` | Click handler | `@click="count++"` |
| `@keydown.enter` | Enter key handler | `@keydown.enter="submit()"` |
| `:class` | Conditional classes | `:class="{ 'active': on }"` |
| `:disabled` | Conditional disable | `:disabled="!ready"` |
| `$store` | Access global store | `$store.task.value` |

## Learn more

[Alpine.js documentation](https://alpinejs.dev/start-here)

---

Source: https://uproot.science/multiplayer/groups.md

# Grouping participants

Many experiments require participants to interact in pairs or small groups. uproot makes grouping easy with the `GroupCreatingWait` page type, which automatically forms groups as participants arrive.

## Groups are lazy

Groups are only created when participants reach a `GroupCreatingWait` page. uproot does not pre-assign groups when a session starts. Grouping happens at runtime as participants arrive. This means the number of groups depends on how many participants actually show up, not on how many were expected.

## Basic group formation

Create a wait page that groups participants by subclassing `GroupCreatingWait` and setting `group_size`:

```python
class WaitForPartner(GroupCreatingWait):
    group_size = 2
```

When participants reach this page, they wait until enough others arrive to form a group. Once a group forms, all members advance to the next page together.

A wait page only groups participants who have actually opened it. For example, if you advance participants to the wait page from the [admin interface](https://uproot.science/running/admin.md) while their browser is closed, they join a group once they come back and the page loads. Participants are also only grouped with others on the same page: if [`Between`](https://uproot.science/building/operators.md#between) gives participants different page orders, a wait page never groups them with someone who is on a different page at the same position.

```python
page_order = [
    WaitForPartner,
    GamePage,
    Results,
]
```

[See the prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

## Assigning roles with `after_grouping`

Use the `after_grouping` callback to assign roles or initialize group members when the group forms:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2

    @classmethod
    def after_grouping(page, group):
        for player, is_dictator in zip(group.players, [True, False]):
            player.dictator = is_dictator
```

The callback receives the `group` object and runs exactly once when the group is created. All players in the group can then access their assigned attributes.

A participant only joins a group after the wait page’s own [`early`](https://uproot.science/reference/page-methods.md#early) and [`before_always_once`](https://uproot.science/reference/page-methods.md#before_always_once) methods have finished for them. So anything these methods save on `player` is already there when `after_grouping` runs.

[See the dictator_game example](https://github.com/mrpg/uproot-examples/tree/main/dictator_game) · [trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game) · [ultimatum_game example](https://github.com/mrpg/uproot-examples/tree/main/ultimatum_game) · [gift_exchange_game example](https://github.com/mrpg/uproot-examples/tree/main/gift_exchange_game)

### Using class attributes for role values

You can define role values as class attributes for cleaner code:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2
    watch_values = (True, False)

    @classmethod
    def after_grouping(page, group):
        for player, watched in zip(group.players, page.watch_values):
            player.watched = watched
```

[See the observed_diary example](https://github.com/mrpg/uproot-examples/tree/main/observed_diary)

## Accessing group members

### `group.players`

Get all players in a group as a `StorageBunch` using the virtual field:

```python
for player in group.players:
    player.payoff = 10
```

The `StorageBunch` supports iteration, filtering, and bulk operations.

#### Basic iteration

```python
# Sum contributions from all group members
total = sum(p.contribution for p in group.players)

# Unpack directly (e.g. in SynchronizingWait.all_here)
p1, p2 = group.players
```

#### Filtering with `_`

The `_` symbol is a field referent: a placeholder that stands for “each player” in a filter expression. When you write `_.cooperate == True`, uproot builds a comparison object that gets evaluated against each player in the collection.

```python
# Players who cooperated
cooperators = group.players.filter(_.cooperate == True)

# Players with a score above 50
high_scorers = session.players.filter(_.score > 50)

# Combine multiple conditions (all must be true)
eligible = session.players.filter(_.present == True, _.age >= 18)
```

`_` supports all comparison operators: `==`, `!=`, `>`, `>=`, `<`, `<=`. You can also chain attribute access. For instance, `_.group.round` refers to each player’s group’s round field.

!!! note
    `_` builds a lazy comparison, so `_.active` alone (without a comparison operator) tests for truthiness. To check for `False`, write `_.active == False` explicitly.

#### Finding a single player

`find_one()` returns exactly one player matching the criteria. It raises an error if zero or multiple players match.

```python
dictator = group.players.find_one(dictator=True)
leader = group.players.find_one(first_mover=True)
```

#### Extracting values with `each`

`each()` collects a field from every player into a list:

```python
# Get all contributions as a list
contributions = group.players.each("contribution")
# → [10, 20, 15]

# Multiple fields return named tuples
data = group.players.each("name", "score", simplify=False)
# → [data(name='Alice', score=10), data(name='Bob', score=20)]
```

#### Bulk assignment with `assign`

`assign()` sets a field on every player from an iterable:

```python
group.players.assign("role", ["buyer", "seller"])
```

#### Running a function on all players with `apply`

`apply()` calls a function once per player:

```python
group.players.apply(set_payoff)
```

See [Synchronizing progress](https://uproot.science/multiplayer/synchronization.md#using-apply-for-bulk-operations) for a full example.

### `player.other_in_group`

For two-person groups, access the other player via the virtual field:

```python
other = player.other_in_group

if player.cooperate and other.cooperate:
    player.payoff = 10
```

Raises an error if the group does not have exactly two members.

[See the prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma) · [twobytwo example](https://github.com/mrpg/uproot-examples/tree/main/twobytwo)

### `player.others_in_group`

For groups of any size, get all other members (excluding the current player):

```python
for other in player.others_in_group:
    notify(player, other, "Hello!")
```

## Group-level data storage

Access the shared group storage with `player.group`:

```python
class Calculate(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        # Store a value at the group level
        group.total_contribution = sum(p.contribution for p in group.players)
```

In templates, access group data:

```html+jinja
<p>Your group contributed {{ player.group.total_contribution }} in total.</p>
```

`session.groups` lists every group in the session. In a multi-app config, pass `app=` to keep only groups created in that app:

```python
for group in session.groups(app=__name__):
    print(group.name, len(group.players))
```

## Larger groups

For experiments with more than two players per group, simply increase `group_size`:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 3
```

Use the same patterns for accessing members:

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        total = sum(p.contribution for p in group.players)

        for player in group.players:
            player.payoff = ENDOWMENT - player.contribution + MPCR * total
```

[See the public_goods_game example](https://github.com/mrpg/uproot-examples/tree/main/public_goods_game) · [beauty_contest example](https://github.com/mrpg/uproot-examples/tree/main/beauty_contest) · [minimum_effort_game example](https://github.com/mrpg/uproot-examples/tree/main/minimum_effort_game)

## Manual group creation

For custom matching logic, you can create groups programmatically using `create_group()` and `create_groups()` instead of `GroupCreatingWait`. This is useful when you need to:

- Match participants based on survey responses
- Form groups of different sizes
- Sort or balance groups by some criteria

### Basic manual grouping

Use `SynchronizingWait` to wait for all participants, then create groups in the `all_here` callback:

```python
class WaitForEveryone(SynchronizingWait):
    synchronize = "session"

    @classmethod
    def all_here(page, session):
        # Get all players and sort alphabetically by name
        all_players = sorted(session.players, key=lambda p: p.name)

        if len(all_players) == 1:
            # Only one player
            create_group(session, all_players)
        else:
            # Split into two groups of roughly equal size
            mid = len(all_players) // 2
            group_a = all_players[:mid]
            group_b = all_players[mid:]

            create_groups(session, [group_a, group_b])

class ShowGroup(Page):
    @classmethod
    def templatevars(page, player):
        group_members = sorted(player.group.players, key=lambda p: p.name)
        return dict(
            group_name=player.group.name,
            group_members=group_members,
        )

page_order = [
    WaitForEveryone,
    ShowGroup,
]
```

[See the grouping example](https://github.com/mrpg/uproot-examples/tree/main/grouping) · [grouping_arbitrary_size example](https://github.com/mrpg/uproot-examples/tree/main/grouping_arbitrary_size) · [grouping_one_spare example](https://github.com/mrpg/uproot-examples/tree/main/grouping_one_spare) · [group_with_dropout example](https://github.com/mrpg/uproot-examples/tree/main/group_with_dropout)

### `create_group()`

Creates a single group from a list of players:

```python
# Create a group from specific players
gid = create_group(session, [player1, player2])

# With a custom group name
gid = create_group(session, members, gname="custom_name")

# Allow reassigning players already in groups
gid = create_group(session, members, overwrite=True)
```

### `create_groups()`

Creates multiple groups at once:

```python
# Create pairs from a list of players
all_players = list(session.players)
pairs = [[all_players[i], all_players[i+1]] for i in range(0, len(all_players), 2)]
gids = create_groups(session, pairs)
```

### `add_to_group()`

Adds players to an existing group. This is useful when latecomers need to join a group that has already been created:

```python
# Add a single player
add_to_group(session.groups[0], player)

# Add multiple players
add_to_group(session.groups[0], [player1, player2])

# Allow reassigning players already in a group
add_to_group(session.groups[0], player, overwrite=True)
```

Players receive sequential `member_id` values starting after the existing members.

### Grouping by attribute

Match participants based on their responses:

```python
class WaitAndMatch(SynchronizingWait):
    synchronize = "session"

    @classmethod
    def all_here(page, session):
        # Separate players by their preference
        prefer_a = [p for p in session.players if p.preference == "A"]
        prefer_b = [p for p in session.players if p.preference == "B"]

        # Match players with different preferences
        for p1, p2 in zip(prefer_a, prefer_b):
            create_group(session, [p1, p2])
```

## Groups in multi-app configs

When a config contains multiple apps that each use `GroupCreatingWait`, groups from a previous app are automatically cleared when participants reach a new `GroupCreatingWait` page. This means each app forms its own independent groups without manual cleanup.

## Complete example: prisoner’s dilemma

Here is a complete two-player game showing group formation, decision collection, and payoff calculation:

```python
DESCRIPTION = "Prisoner's dilemma"
SUGGESTED_MULTIPLE = 2

class C:
    PAYOFF_MATRIX = {
        # (you cooperate, other cooperates): your payoff
        (True, True): 10,
        (True, False): 0,
        (False, True): 15,
        (False, False): 3,
    }

class Instructions(Page):
    pass

class GroupPlease(GroupCreatingWait):
    group_size = 2

class Dilemma(Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )

class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group: GroupType) -> None:
        for player in group.players:
            player.payoff = C.PAYOFF_MATRIX[
                player.cooperate,
                player.other_in_group.cooperate,
            ]

class Results(Page):
    pass

page_order = [
    Instructions,
    GroupPlease,
    Dilemma,
    Sync,
    Results,
]
```

[See the full prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

## Summary

| | Purpose |
|--|---------|
| `GroupCreatingWait` | Wait page that forms groups automatically |
| `group_size` | Number of players per group |
| `after_grouping(page, group)` | Callback when group forms |
| `group.players` | All players in a group |
| `player.other_in_group` | The other player (2-person groups) |
| `player.others_in_group` | All other players in the group |
| `session.players` | All players in a session |
| `player.group` | Access group-level storage |
| `create_group()` | Programmatically create a group |
| `add_to_group()` | Add players to an existing group |

---

Source: https://uproot.science/multiplayer/synchronization.md

# Synchronizing progress

In multiplayer experiments, you often need to wait for all group members to reach a certain point before continuing. The `SynchronizingWait` page type handles this automatically and provides a callback to process all players’ data together.

## Basic synchronization

Create a wait page that synchronizes players by subclassing `SynchronizingWait`:

```python
class WaitForAll(SynchronizingWait):
    pass
```

When a participant reaches this page, they wait until all other group members arrive. Once everyone is present, they all advance together.

```python
page_order = [
    GroupPlease,      # Form groups first
    Decision,         # Each player makes a decision
    WaitForAll,       # Wait for all decisions
    Results,          # Show results
]
```

## Processing data with `all_here`

The `all_here` callback runs exactly once when all group members have arrived. This is where you calculate outcomes based on everyone’s choices:

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        total = sum(p.contribution for p in group.players)

        for player in group.players:
            player.payoff = ENDOWMENT - player.contribution + MPCR * total
```

The callback receives the `group` object and has access to all players’ data.

[See the public_goods_game example](https://github.com/mrpg/uproot-examples/tree/main/public_goods_game) · [beauty_contest example](https://github.com/mrpg/uproot-examples/tree/main/beauty_contest) · [minimum_effort_game example](https://github.com/mrpg/uproot-examples/tree/main/minimum_effort_game)

## Calculating payoffs

A common pattern is to calculate payoffs for all players in `all_here`. Here is how the prisoner’s dilemma looks up each player’s payoff in a payoff matrix:

```python
class C:
    PAYOFF_MATRIX = {
        # (you cooperate, other cooperates): your payoff
        (True, True): 10,
        (True, False): 0,
        (False, True): 15,
        (False, False): 3,
    }

class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group: GroupType) -> None:
        for player in group.players:
            player.payoff = C.PAYOFF_MATRIX[
                player.cooperate,
                player.other_in_group.cooperate,
            ]
```

Each key of `PAYOFF_MATRIX` is a pair of decisions: first that of the player who receives the payoff, then that of the other player. Saving the result as `player.payoff` puts it in your data export.

[See the prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

### Using apply for bulk operations

You can also use `apply()` to run a function on all players:

```python
class Sync(SynchronizingWait):
    @classmethod
    def set_payoff(page, player):
        other = player.other_in_group

        if player.claim + other.claim <= 100:
            player.payoff = player.claim

    @classmethod
    def all_here(page, group):
        group.players.apply(page.set_payoff)
```

[See the focal_point example](https://github.com/mrpg/uproot-examples/tree/main/focal_point) · [prisoners_dilemma_fancy example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_fancy)

## Finding specific players

Use `find_one()` to locate players with specific attributes:

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        dictator = group.players.find_one(dictator=True)
        recipient = group.players.find_one(dictator=False)

        dictator.payoff = cu(10) - dictator.give
        recipient.payoff = dictator.give
```

[See the dictator_game example](https://github.com/mrpg/uproot-examples/tree/main/dictator_game) · [trust_game example](https://github.com/mrpg/uproot-examples/tree/main/trust_game) · [ultimatum_game example](https://github.com/mrpg/uproot-examples/tree/main/ultimatum_game)

## Session-level synchronization

By default, `SynchronizingWait` synchronizes within the group. To synchronize across the entire session instead:

```python
class WaitForSession(SynchronizingWait):
    synchronize = "session"

    @classmethod
    def all_here(page, session):
        # Runs when ALL participants in the session have arrived
        session.total_participants = len(session.players)
```

Note that when `synchronize = "session"`, the callback receives the `session` object instead of `group`.

## Custom synchronization with `wait_for`

Override the `wait_for` method for custom synchronization logic:

```python
class CustomWait(SynchronizingWait):
    @classmethod
    def wait_for(page, player):
        # Return list of PlayerIdentifiers to wait for
        # Default implementation returns player.group.players
        return player.group.players
```

## Synchronization in repeated games

When using `Rounds`, each round typically needs its own synchronization point:

```python
page_order = [
    GroupPlease,
    Rounds(
        Decision,
        Sync,           # Sync after each round
        RoundResults,
        n=3,
    ),
    FinalResults,
]
```

The `all_here` callback runs once per round, allowing you to calculate round-specific outcomes:

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        for player in group.players:
            set_payoff(player)  # Payoff for this round
```

In templates, you can access the current round number:

```html+jinja
<h2>Round {{ player.round }} results</h2>
```

[See the prisoners_dilemma_repeated example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_repeated)

## Accessing historical data in repeated games

Use `player.within()` to access data from specific rounds:

```python
def digest(session):
    for group in session.groups:
        player1 = group.players.find_one(member_id=0)

        for round in range(1, player1.round + 1):
            # Get this player’s choice in a specific round
            choice = player1.within(round=round).get("cooperate")
```

[See the prisoners_dilemma_repeated example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_repeated)

## Complete example: public goods game

Here is a complete example showing synchronization with a three-player group:

```python
ENDOWMENT = cu("10")
MPCR = cu("0.5")  # Marginal Per Capita Return

class GroupPlease(GroupCreatingWait):
    group_size = 3

class Contribute(Page):
    fields = dict(
        contribution=DecimalField(
            label="How much do you contribute to the group account?",
            min=0,
            max=ENDOWMENT,
        ),
    )

    @classmethod
    def templatevars(page, player):
        group_size = GroupPlease.group_size
        multiplier = MPCR * group_size

        return dict(
            endowment=ENDOWMENT,
            group_size=group_size,
            multiplier=multiplier,
        )

class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        total = sum(p.contribution for p in group.players)

        for player in group.players:
            player.payoff = ENDOWMENT - player.contribution + MPCR * total

class Results(Page):
    @classmethod
    def templatevars(page, player):
        total = sum(p.contribution for p in player.group.players)
        return dict(total=total)

page_order = [
    GroupPlease,
    Contribute,
    Sync,
    Results,
]
```

[See the full public_goods_game example](https://github.com/mrpg/uproot-examples/tree/main/public_goods_game)

## Detecting presence

In classroom or lab experiments you sometimes need to know which participants are actively present before forming groups or proceeding. The `detect_presence` pattern uses a short time window during which participants must signal that they are there:

```python
from time import time as now

class C:
    DETECTION_PERIOD = 30.0  # seconds

class RaiseHands(Page):
    @classmethod
    def timeout(page, player):
        if player.session.get("detection_period_until") is None:
            player.session.detection_period_until = now() + C.DETECTION_PERIOD
            for p in player.session.players:
                p.present = False
        return player.session.detection_period_until - now()

    @classmethod
    def may_proceed(page, player):
        return now() >= player.session.detection_period_until

    @live
    def set_presence(page, player, new_value: bool):
        player.present = new_value
        return new_value
```

After the page completes, `player.present` tells you whether each participant signalled their presence. You can then use this in `show()`, `all_here()`, or when forming groups.

[See the full detect_presence example](https://github.com/mrpg/uproot-examples/tree/main/detect_presence)

## Summary

| Feature | Purpose |
|---------|---------|
| `SynchronizingWait` | Wait page that syncs group members |
| `all_here(page, group)` | Callback when all members arrive |
| `synchronize = "group"` | Sync within group (default) |
| `synchronize = "session"` | Sync entire session |
| `wait_for(page, player)` | Custom sync logic |
| `group.players.find_one()` | Find player by attribute |
| `group.players.apply()` | Run function on all players |
| `player.within(round=n)` | Access data from specific round |

---

Source: https://uproot.science/multiplayer/real-time.md

# Real-time interactions

uproot supports real-time communication between participants through WebSockets. This page covers broadcasting updates to multiple participants. For the basics of live methods (the `@live` decorator and `uproot.invoke`), see [Live methods](https://uproot.science/building/live-methods.md).

## Two patterns: return values vs. notifications

There are two ways to send data back to the browser:

1. **Return values:** Data goes back to the caller only.
2. **Notifications:** Data is pushed to one or more participants.

```python
@live
async def post_offer(page, player, price: float):
    player.my_offer = price

    # Broadcast to everyone (notification)
    notify(player, player.session.players, price, event="NewOffer")

    # Return to the caller only
    return {"posted": price}
```

Use return values for request-response patterns. Use `notify` for broadcasting to multiple participants.

## Broadcasting with `notify`

`notify` sends data to one or more participants. By default it only delivers to
recipients currently on the sender’s page (`where=sender.show_page`):

```python
# Notify one player
notify(player, other_player, data)

# Notify all players in a group
notify(player, player.group.players, data)

# Notify all players in a session
notify(player, player.session.players, data)

# Notify everyone except the sender
notify(player, player.others_in_group, data)
```

The first argument is the sender (used to determine the current page context).
The second is the recipient(s). Use `where=...` to deliver regardless of the
recipient’s current page, or pass a page index to target a specific page.

### Custom events

Name your notifications with the `event` parameter:

```python
notify(player, player.session.players, market_data, event="MarketUpdate")
```

Listen for specific events in JavaScript:

```javascript
uproot.onCustomEvent("MarketUpdate", (event) => {
    refreshMarketDisplay(event.detail.data);
});
```

### Default event handling

Without a custom event name, data goes to `uproot.receive`:

```python
notify(player, other_player, "Hello!")
```

```javascript
uproot.receive = (data) => {
    console.log(data);  // "Hello!"
};
```

## `send_to` for server-initiated updates

Use `send_to` when you do not have a sender context (like in background tasks):

```python
from uproot.smithereens import send_to

send_to(player, data)
send_to(session.players, data, event="StatusUpdate")
```

## Example: live text observation

One participant types while another watches in real-time:

```python
class Diary(Page):
    @live
    async def typed(page, player, text: str):
        observer = player.other_in_group
        notify(player, observer, text)
```

```javascript
// Writer
textarea.oninput = () => {
    uproot.invoke("typed", textarea.value);
};

// Observer
uproot.receive = (text) => {
    display.innerText = text;
};
```

[See the observed_diary example](https://github.com/mrpg/uproot-examples/tree/main/observed_diary)

## Example: collaborative drawing

Multiple participants drawing on a shared canvas:

```python
class Draw(Page):
    @live
    async def stroke(page, player, points: list, color: str):
        with player.session as session:
            session.strokes.append({"points": points, "color": color})

        # Send to others (not the sender)
        notify(player, player.others_in_session, {"points": points, "color": color}, event="NewStroke")
```

```javascript
uproot.onCustomEvent("NewStroke", (event) => {
    drawStroke(event.detail.data.points, event.detail.data.color);
});
```

[See the drawing_board example](https://github.com/mrpg/uproot-examples/tree/main/drawing_board)

## Example: real-time market

A trading interface where participants post and accept offers:

```python
class Market(Page):
    @live
    async def post_offer(page, player, price: float):
        player.my_offer = price

        # Broadcast updated market to everyone
        notify(player, player.session.players, list(player.session.offers), event="MarketUpdate")
        return {"posted": price}

    @live
    async def accept_offer(page, player, seller_id: str):
        seller = player.session.players.get(seller_id)
        price = seller.my_offer

        player.bought_at = price
        seller.sold_at = price
        seller.my_offer = None

        # Notify the seller directly
        notify(player, seller, price, event="OfferAccepted")

        # Update market for everyone
        notify(player, player.session.players, list(player.session.offers), event="MarketUpdate")
        return {"bought_at": price}
```

```javascript
uproot.onCustomEvent("MarketUpdate", (event) => {
    renderMarket(event.detail.data);
});

uproot.onCustomEvent("OfferAccepted", (event) => {
    showNotification(`Your offer was accepted at ${event.detail.data}!`);
});
```

[See the double_auction example](https://github.com/mrpg/uproot-examples/tree/main/double_auction)

## Background tasks with `spawn`

Sometimes you need work to continue after a `@live` method returns. Examples are calling an external API or running a loop that pushes updates every few seconds. `spawn` launches an async function in the background so it keeps running independently:

```python
async def ask_llm(player):
    response = await call_api(player.question)
    player.answer = response
    send_to(player, response, event="Answer")

class Question(Page):
    @live
    async def submit_question(page, player, text: str):
        player.question = text
        spawn(ask_llm(player))   # returns immediately; ask_llm keeps running
        return {"status": "thinking"}
```

The spawned task is supervised: Exceptions are logged instead of silently swallowed, and all tasks are cancelled when the server shuts down. Tasks do **not** survive server restarts. If your server restarts and you need the task again, re-launch it from the `restart()` function in your app module.

```python
async def tick(session):
    while True:
        with session:
            session.counter += 1
            send_to(session.players, session.counter)
        await asyncio.sleep(1)

async def restart():
    # Re-launch background tasks after a server restart
    from uproot.storage import Admin
    with Admin() as admin:
        for session in admin.sessions:
            if session.get("counter") is not None:
                spawn(tick(session))
```

!!! warning
    Because spawned tasks run outside a page method, uproot does not auto-track mutations to lists and dicts. Wrap writes in a context manager: `with player as p:` or `with session as s:`.

[See the continuous example](https://github.com/mrpg/uproot-examples/tree/main/continuous) · [chat_with_claude example](https://github.com/mrpg/uproot-examples/tree/main/chat_with_claude)

## Summary

| Feature | Use case |
|---------|----------|
| `notify(sender, recipients, data)` | Broadcasting updates to participants |
| `notify(..., event="Name")` | Named events for specific handlers |
| `send_to(recipients, data)` | Server-initiated updates (background tasks) |
| `uproot.onCustomEvent("Name", fn)` | Listening for named events |
| `uproot.receive = fn` | Default handler for unnamed notifications |
| `player.others_in_group` | All group members except sender |
| `player.others_in_session` | All session members except sender |
| `spawn(coroutine)` | Run a background task (see above) |

---

Source: https://uproot.science/multiplayer/chat.md

# Built-in chat

uproot provides a built-in chat system for experiments where participants need to communicate. The chat handles message display, pseudonyms, and real-time updates automatically.

## Creating a chat

Create a chat at the session or group level, typically in the `new_session` callback:

```python
def new_session(session):
    session.chat = chat.create(session)
```

The `chat.create()` function returns a chat identifier that you store on the session for later use.

[See the chat example](https://github.com/mrpg/uproot-examples/tree/main/chat)

## Adding players to a chat

Add participants to the chat using `chat.add_player()`. This is typically done when they reach the chat page:

```python
class ChatPage(Page):
    @classmethod
    async def before_once(page, player):
        chat.add_player(player.session.chat, player)
```

The `before_once` method ensures each player is added exactly once, even if they refresh the page.

### Assigning pseudonyms

You can assign pseudonyms to protect participant identity:

```python
chat.add_player(
    player.session.chat,
    player,
    pseudonym=f"Player {player.id}",
)
```

If no pseudonym is provided, an anonymous identifier is generated automatically.

## Displaying the chat

Use the `chat()` template function to render the chat interface:

```html+jinja
{% extends "Base.html" %}

{% block main %}

{{ chat(session.chat) }}

{% endblock main %}
```

The chat widget provides:

- a message display area showing all messages;
- an input field for typing messages;
- a send button;
- real-time updates when others send messages;
- visual distinction between your messages and others’.

## Session-level chat

The simplest chat setup is session-wide, where all participants can see all messages:

```python
def new_session(session):
    session.chat = chat.create(session)

class ChatPage(Page):
    @classmethod
    async def before_once(page, player):
        chat.add_player(
            player.session.chat,
            player,
            pseudonym=f"Player {player.id}",
        )

page_order = [
    ChatPage,
]
```

[See the chat example](https://github.com/mrpg/uproot-examples/tree/main/chat)

## Group-level chat

For private communication within groups, create a chat per group in the `after_grouping` callback:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2

    @classmethod
    def after_grouping(page, group):
        group.chat = chat.create(group.session)

        for player in group.players:
            chat.add_player(group.chat, player)
```

Then display the group’s chat:

```html+jinja
{{ chat(player.group.chat) }}
```

## Multiple chats

You can create multiple chat channels for different purposes:

```python
def new_session(session):
    session.general_chat = chat.create(session)
    session.announcements = chat.create(session)  # Admin-only channel
```

Display them on the same page or different pages:

```html+jinja
<h3>General discussion</h3>
{{ chat(session.general_chat) }}

<h3>Announcements</h3>
{{ chat(session.announcements) }}
```

## Reacting to new messages

Use `chat.on_message()` to register a callback that runs whenever a message is sent to a chat. This is useful for bots, automated responses, or recording-keeping logic.

```python
async def on_chat_message(chat, player, message):
    if player is None:
        return  # Message was sent programmatically, not by a participant
    # React to the message here

def new_player(player):
    player.my_chat = chat.create(player.session)
    chat.add_player(player.my_chat, player)
    chat.on_message(player.my_chat, on_chat_message)
```

The callback receives:

- `chat`: the `ModelIdentifier` of the chat;
- `player`: the player who sent the message, or `None` if sent programmatically;
- `message`: the message text.

The callback is persistent: it is automatically restored when the server restarts.

[See the chat example with callback](https://github.com/mrpg/uproot-examples/tree/main/chat) · [chat_with_claude example](https://github.com/mrpg/uproot-examples/tree/main/chat_with_claude)

## Chat API reference

### `chat.create(session)`

Creates a new chat channel.

```python
chat_id = chat.create(session)
```

Returns a `ModelIdentifier` that you store on the session or group.

### `chat.add_player(chat, player, pseudonym=None)`

Adds a participant to the chat.

```python
chat.add_player(session.chat, player)
chat.add_player(session.chat, player, pseudonym="Alice")
```

### `chat.messages(chat)`

Returns all messages in the chat.

```python
all_messages = chat.messages(session.chat)
```

### `chat.players(chat)`

Returns the list of players in the chat.

```python
participants = chat.players(session.chat)
```

### `chat.exists(chat)`

Checks if a chat exists.

```python
if chat.exists(session.chat):
    ...
```

## Styling the chat

The chat uses Bootstrap classes and can be customized with CSS. The main elements are:

- `.uproot-chat`: Container for the entire chat widget;
- `.messages-container`: the scrollable message area;
- `.list-unstyled`: the message list.

Example custom styling:

```css
.messages-container {
    height: 400px;
    overflow-y: auto;
}

.uproot-chat .btn {
    min-width: 80px;
}
```

## Complete example

Here is a complete session-level chat:

```python
def new_session(session):
    session.chat = chat.create(session)

class ChatHere(Page):
    @classmethod
    async def before_once(page, player):
        chat.add_player(
            player.session.chat,
            player,
            pseudonym=f"Player {player.id}",
        )

page_order = [
    ChatHere,
]
```

Template (`ChatHere.html`):

```html+jinja
{% extends "Base.html" %}

{% block title %}
Chat
{% endblock title %}

{% block main %}

{{ chat(session.chat) }}

{% endblock main %}
```

[See the full chat example](https://github.com/mrpg/uproot-examples/tree/main/chat) · [prisoners_dilemma_chat example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_chat)

## Summary

| Function | Purpose |
|----------|---------|
| `chat.create(session)` | Create a new chat channel |
| `chat.add_player(chat, player, pseudonym=...)` | Add a participant to the chat |
| `chat.on_message(chat, callback)` | Register a callback for new messages |
| `chat(chat_id)` | Template function to render the chat |
| `chat.messages(chat)` | Get all messages |
| `chat.players(chat)` | Get all participants |

---

Source: https://uproot.science/advanced/timeouts.md

# Page timeouts

Timeouts automatically advance participants to the next page after a specified duration. Use them for timed tasks, real-effort experiments, or to keep participants moving through your study.

## Static timeout

Set a fixed timeout in seconds as a class attribute:

```python
class TimedTask(Page):
    timeout = 60  # Auto-advance after 60 seconds
```

When the timeout expires, the page submits automatically with whatever data has been entered.

## Dynamic timeout

Use a method to calculate the timeout based on player state:

```python
class AdaptiveTask(Page):
    @classmethod
    def timeout(page, player):
        # Faster participants get less time
        base_time = 120
        bonus = player.correct_answers * 5
        return base_time - bonus
```

The method receives `page` and `player` and returns the timeout in seconds. Return `None` to disable the timeout for that player.

## Handling timeout expiration

The `timeout_reached` callback runs when the timeout expires:

```python
class TimedQuiz(Page):
    timeout = 30

    @classmethod
    def timeout_reached(page, player):
        player.timed_out = True
        player.score = 0  # Penalty for not answering in time
```

This callback runs before the page advances. Use it to:

- Record that the participant timed out
- Apply penalties or default values
- Set flags for conditional logic later

## Timeouts and `may_proceed`

A page with a [`may_proceed`](https://uproot.science/reference/page-methods.md#may_proceed) method keeps participants on the page until a condition holds. A timeout overrides this: once the deadline has passed, the page advances even if `may_proceed` returns `False`. This keeps [wait pages](https://uproot.science/multiplayer/synchronization.md) from hanging forever when time runs out.

uproot counts a timeout as reached up to one second before the deadline. In that last second, the page only advances if `may_proceed` allows it. So if the next page relies on the deadline having passed, check the deadline in `may_proceed`:

```python
class Negotiate(Page):
    @classmethod
    def timeout(page, player):
        from time import time
        return max(0, player.group.deadline - time())

    @classmethod
    def may_proceed(page, player):
        from time import time
        return player.group.agreed or time() >= player.group.deadline
```

Without `may_proceed`, a participant can reach the next page up to one second before the deadline. To change this tolerance, set `upd.TIMEOUT_TOLERANCE` (in seconds) in `main.py`.

## Timeout spanning multiple pages

For a shared timeout across several pages, store the deadline and calculate remaining time dynamically:

```python
class InitializeTimeout(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        from time import time
        player.deadline = time() + 60  # 60 seconds total
        player.failed = False

class TimedPage(Page):
    @classmethod
    def timeout(page, player):
        from time import time
        return max(0, player.deadline - time())

    @classmethod
    def timeout_reached(page, player):
        if not player.failed:
            player.failed = True

class Task1(TimedPage):
    pass

class Task2(TimedPage):
    pass

class Task3(TimedPage):
    pass

page_order = [
    InitializeTimeout,
    Task1,
    Task2,
    Task3,
    Results,
]
```

All three task pages share the same 60-second deadline. If time runs out on any page, `player.failed` is set.

[See the timeout_multipage example](https://github.com/mrpg/uproot-examples/tree/main/timeout_multipage)

## Timeouts with live methods

Timeouts work well with live methods for real-effort tasks:

```python
class Sumhunt(Page):
    timeout = 120  # 2 minutes to solve puzzles

    @live
    async def submit_answer(page, player, answer: int):
        if answer == player.correct_answer:
            player.score += 1
            player.puzzle = generate_new_puzzle()
        return player.puzzle
```

The participant interacts via live methods until the timeout advances them.

[See the sumhunt example](https://github.com/mrpg/uproot-examples/tree/main/sumhunt) · [encryption_task example](https://github.com/mrpg/uproot-examples/tree/main/encryption_task)

## Hiding the countdown display

Sometimes participants should not see how much time is left, for example when time pressure is part of your design. To hide the countdown, override the `timeoutbox` block with an empty one in your page’s template:

```html+jinja
{% extends "Base.html" %}

{% block timeoutbox %}{% endblock timeoutbox %}

{% block title %}
Negotiation
{% endblock title %}

{% block main %}
<p>Make your offer.</p>
{% endblock main %}
```

The timeout still applies. Only the display is gone.

## Repositioning the countdown display

uproot automatically shows a countdown timer in `#uproot-timeout`. To move it elsewhere on your page, relocate the `#uproot-time-remaining` element with JavaScript:

```html+jinja
<p>Time remaining: <span id="time-here"></span></p>

<script>
document.getElementById("time-here").appendChild(
    document.getElementById("uproot-time-remaining")
);
document.getElementById("uproot-timeout").remove();
</script>
```

This moves the countdown into your custom container and removes the default wrapper.

## Checking timeout status in templates

Access the timeout flag in your results template:

```html+jinja
{% if player.timed_out %}
<p>You ran out of time.</p>
{% else %}
<p>You completed the task in time.</p>
{% endif %}
```

## Advanced: JavaScript timeout API

The `uproot` object exposes timeout state and events for custom interfaces. The
countdown uses the monotonic `performance.now()` clock, not a Unix deadline.

### Reading timeout state

The current state is available in the built-in Alpine store:

```javascript
const timeout = Alpine.store("uproot").timeout;
// { active, level, text, compact }
```

For lower-level access, use `uproot.pageTimeoutRemainingMs()` while a timeout
is active. The `pageTimeoutStartMs` and `pageTimeoutDurationMs` properties are
also available, but the Alpine store is the more stable interface.

### Timeout events

Two custom events fire on `window`:

```javascript
// Fires once when the timeout is set
window.addEventListener("UprootInternalPageTimeoutSet", (event) => {
    console.log("Timeout started:", event.detail);
});

// Fires every second during countdown
window.addEventListener("UprootInternalPageTimeout", (event) => {
    updateCustomDisplay(event.detail);
});
```

### Visual feedback classes

The default `#uproot-timeout` element automatically changes its timeout level
class based on the remaining time:

| Remaining time | Class added |
|----------------|-------------|
| More than 60 seconds | `uproot-timeout-light` |
| 15–60 seconds | `uproot-timeout-warning` |
| Less than 15 seconds | `uproot-timeout-danger` |

### Custom countdown display

Combine these APIs for a fully custom countdown:

```html+jinja
<div id="my-timer" class="display-4"></div>

<script>
window.addEventListener("UprootInternalPageTimeout", () => {
    const secs = Math.max(0, Math.ceil(uproot.pageTimeoutRemainingMs() / 1000));
    const mins = Math.floor(secs / 60);
    const remainder = secs % 60;
    document.getElementById("my-timer").innerText =
        `${mins}:${remainder.toString().padStart(2, "0")}`;
});
</script>
```

To hide the default display, override the `timeoutbox` block as shown in [Hiding the countdown display](#hiding-the-countdown-display).

## Summary

| Feature | Purpose |
|---------|---------|
| `timeout = 60` | Static timeout in seconds |
| `def timeout(page, player)` | Dynamic timeout calculation |
| `def timeout_reached(page, player)` | Callback when timeout expires |
| Return `None` from timeout | Disable timeout for that player |
| `{% block timeoutbox %}{% endblock timeoutbox %}` | Hide the countdown display |
| `Alpine.store("uproot").timeout` | JavaScript: current timeout state |
| `UprootInternalPageTimeoutSet` | JavaScript: event when timeout starts |
| `UprootInternalPageTimeout` | JavaScript: event every second |

---

Source: https://uproot.science/advanced/dropouts.md

# Handling dropouts

In multiplayer experiments, participants may close their browser or lose their connection. uproot provides tools to detect dropouts and handle them gracefully.

## Automatic dropout detection

Use `watch_for_dropout` to monitor a player and trigger a callback when they go offline:

```python
def new_player(player):
    player.dropout = False
    watch_for_dropout(player, handle_dropout)

async def handle_dropout(player):
    player.dropout = True
    move_to_end(player)
```

The watcher checks every few seconds whether the player’s browser is still connected. If the player has been offline for longer than the tolerance period (default: 30 seconds), the handler fires.

[See the dropouts example](https://github.com/mrpg/uproot-examples/tree/main/dropouts)

## `watch_for_dropout`

```python
watch_for_dropout(player, handler, tolerance=30.0)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `player` | player | — | The player to watch |
| `handler` | async function | — | Called when dropout is detected |
| `tolerance` | `float` | `30.0` | Seconds of inactivity before triggering |

The handler receives the `player` as its argument. It can be an async function.

Register the watcher in `new_player` so it starts monitoring as soon as the player joins.

## Moving dropouts to the end

The most common response to a dropout is to move them to the end page:

```python
async def handle_dropout(player):
    player.dropout = True
    move_to_end(player)
```

`move_to_end(player)` advances the player past all remaining pages. If they return, they will see the end page instead of being stuck on a wait page that blocks other players.

## Marking dropouts manually

From the admin interface, select players and use the **Mark as dropout** action.
This sets `player._uproot_dropout = True` and triggers the admin dropout flow.

Programmatically:

```python
from uproot.smithereens import mark_dropout

mark_dropout(player.pid)
```

`mark_dropout` marks the player for the dropout watcher. Register a watcher if
you want a handler to run; the function alone does not move the player or call
the handler immediately.

## Handling dropouts in group experiments

In multiplayer experiments, a dropout can block other group members at synchronization points. A single participant closing their browser, walking away, or losing connectivity can leave their partner staring at “waiting for other participants” indefinitely.

A robust solution tracks dropout at the *group* level and handles three distinct dropout vectors:

1. **Browser disconnect:** The participant closes the tab or loses connectivity (`watch_for_dropout`).
2. **Page timeout:** The participant sits on a decision page without submitting (`timeout_reached`).
3. **Sync timeout:** One participant submits but their partner never arrives at the wait page (`timeout` on `SynchronizingWait`).

### The group-level drop pattern

Use a boolean `group.dropped` flag as the single source of truth. When any dropout vector fires, mark the group and redirect all other members to a dedicated Dropped page:

```python
class C:
    TIMEOUT = 60       # seconds to make a decision
    SYNC_TIMEOUT = 90  # seconds to wait at sync points

def drop_group(group, culprit):
    group.dropped = True
    group.dropped_by = culprit.name
    for p in group.players:
        if p.name != culprit.name:
            with p as pp:
                move_to_page(pp, Dropped)
```

`drop_group` does three things: marks the group, records who caused the drop, and moves every *other* player to the Dropped page. The `with p as pp:` [context manager](https://uproot.science/building/data.md) is required here because you are mutating a player object from outside that player’s own page method.

!!! warning "Guard against double-dropping"
    Multiple dropout vectors can fire for the same group (e.g., a browser disconnect triggers `watch_for_dropout` at the same moment a page timeout fires). Always check `if not group.get("dropped")` before calling `drop_group`.

### Registering watchers in `after_grouping`

Register `watch_for_dropout` in `after_grouping`, not `new_player`, because the handler needs access to the group. When iterating `group.players` inside `after_grouping`, wrap each player in a context manager:

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2

    @classmethod
    def after_grouping(page, group):
        group.dropped = False
        for player in group.players:
            player.timed_out = False

            with player:
                watch_for_dropout(player, handle_dropout)
```

The `with player:` block is needed because `after_grouping` receives the group, and iterating `group.players` yields player objects that require the context manager for uproot to track their mutations properly.

### The dropout handler

The handler fires when a participant disconnects. It marks the player as timed out, drops the group if it has not been dropped already, and moves the disconnected player to the end:

```python
async def handle_dropout(player):
    player.timed_out = True
    group = player.group
    if group is not None and not group.get("dropped"):
        drop_group(group, player)
    move_to_end(player)
```

### Page timeouts on decision pages

Add a [timeout](https://uproot.science/advanced/timeouts.md) to every decision page. When it expires, `timeout_reached` drops the group:

```python
class Dilemma(Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )

    @classmethod
    def timeout(page, player):
        return C.TIMEOUT

    @classmethod
    def timeout_reached(page, player):
        player.timed_out = True
        if not player.group.get("dropped"):
            drop_group(player.group, player)

    @classmethod
    def before_once(page, player):
        if player.group.get("dropped"):
            move_to_page(player, Dropped)
```

The `before_once` guard at the bottom is equally important: If the group was already dropped (because the *other* player timed out or disconnected), this player should not see the decision page at all. Instead, they get redirected to `Dropped` immediately.

!!! tip
    If you have many decision pages, you can avoid repeating these three methods on each one. See [Reducing repetition with a mixin class](#appendix-reducing-repetition-with-a-mixin-class) at the end of this page.

### Sync timeout

A `SynchronizingWait` page can also time out. This covers the case where one player submits their decision but the other never arrives:

```python
class Sync(SynchronizingWait):
    @classmethod
    def timeout(page, player):
        return C.SYNC_TIMEOUT

    @classmethod
    def timeout_reached(page, player):
        if not player.group.get("dropped"):
            player.group.dropped = True
            player.group.dropped_by = "sync_timeout"

    @classmethod
    def all_here(page, group):
        if group.get("dropped"):
            return
        for player in group.players:
            # ... compute and assign payoffs ...
```

The guard in `all_here` prevents payoff calculation for dropped groups.

### The Dropped page

Create a terminal page that tells participants what happened. Distinguish between the player who timed out and their partner:

```html+jinja
{% extends "Base.html" %}

{% block title %}Time expired{% endblock title %}

{% block main %}
{% if player.timed_out %}
<p>You did not make a choice in time. Your pair has been removed from this round.</p>
{% else %}
<p>Your partner did not make a choice in time. Your pair has been removed from this round.</p>
{% endif %}
{% endblock main %}
```

In your Python code, the Dropped page moves the player to the end after they see the message:

```python
class Dropped(Page):
    @classmethod
    def after_once(page, player):
        move_to_end(player)
```

Add Dropped to the end of `page_order`:

```python
page_order = [
    GroupPlease,
    Dilemma,
    Sync,
    Results,
    Dropped,
]
```

### Guarding downstream pages

Every page after grouping should check the drop flag and redirect. Add a `before_once` guard to your Results page (and any other post-sync page). (If you have many such pages, see the [mixin appendix](#appendix-reducing-repetition-with-a-mixin-class).)

```python
class Results(Page):
    @classmethod
    def before_once(page, player):
        if player.group.get("dropped"):
            move_to_page(player, Dropped)
```

### Excluding dropped groups from data

If your experiment uses a `digest` function, skip dropped groups so they do not contaminate the summary:

```python
def digest(session):
    dropped_pairs = 0

    for group in session.groups(app=__name__):
        if group.get("dropped"):
            dropped_pairs += 1
            continue

        # ... normal payoff analysis ...

    return {
        # ...
        "dropped_pairs": dropped_pairs,
    }
```

If your experiment uses a `pipeline` function, include `timed_out` and `dropped` so you can filter in downstream analysis:

```python
def pipeline(session, data=None):
    rows = []
    for group in session.groups(app=__name__):
        for player in group.players:
            player_data = player.within(app=__name__)
            rows.append({
                # ... other fields ...
                "timed_out": player_data.get("timed_out"),
                "dropped": group.get("dropped"),
            })
    return rows
```

### Showing dropout counts in the admin digest

If your experiment uses an `AdminDigest.html` template, display a warning when groups were dropped:

```html+jinja
{% if dropped_pairs > 0 %}
<div class="alert alert-warning mb-3">
    {{ dropped_pairs }} pair{{ "s" if dropped_pairs != 1 }} dropped due to timeout.
</div>
{% endif %}
```

## Adjusting tolerance

Lower the tolerance for fast-paced experiments where delays are critical:

```python
watch_for_dropout(player, handle_dropout, tolerance=10.0)
```

Increase it for experiments where participants may be reading long instructions:

```python
watch_for_dropout(player, handle_dropout, tolerance=120.0)
```

## Checking dropout status in templates

```html+jinja
{% if player.dropout %}
<p>This player has been marked as a dropout.</p>
{% endif %}
```

## Summary

| Feature | Purpose |
|---------|---------|
| `watch_for_dropout(player, handler, tolerance=30.0)` | Monitor a player for disconnection |
| `move_to_end(player)` | Move player past all remaining pages |
| `move_to_page(player, PageClass)` | Redirect a player to a specific page |
| `mark_dropout(pid)` | Manually mark a player as dropout |
| `player._uproot_dropout` | Internal dropout flag |
| Admin: Mark as dropout | Manual dropout from admin interface |
| `group.dropped` | Group-level flag for tracking dropped groups |
| `with p as pp:` / `with player:` | Context manager for cross-player mutations |
| `group.get("dropped")` | Safe check that returns `None` if not set |
| `before_once` guard | Redirect dropped players away from normal pages |

---

## Appendix: reducing repetition with a mixin class

!!! note "Optional—for readers comfortable with Python classes"
    This section shows a convenience pattern for experiments with many decision pages. It is not required. If you only have one or two decision pages, adding the methods directly (as shown above) is simpler and perfectly fine.

In the group dropout pattern above, every decision page needs the same three methods: `timeout`, `timeout_reached`, and `before_once`. If your experiment has several decision pages, repeating those methods on each one gets tedious. Python lets you factor them out into a **[mixin class](https://docs.python.org/3/tutorial/classes.html#multiple-inheritance)**. This is a small class that bundles reusable methods and can be combined with `Page` (or any other page type) via [multiple inheritance](https://realpython.com/inheritance-composition-python/#mixing-features-with-mixin-classes).

### Defining the mixin

```python
class DroppableMixin:
    """Adds timeout + dropout guard to any page."""

    @classmethod
    def timeout(page, player):
        return C.TIMEOUT

    @classmethod
    def timeout_reached(page, player):
        player.timed_out = True
        if not player.group.get("dropped"):
            drop_group(player.group, player)

    @classmethod
    def before_once(page, player):
        if player.group.get("dropped"):
            move_to_page(player, Dropped)
```

### Using the mixin

Place `DroppableMixin` *before* `Page` in the class definition. Python checks classes left to right, so methods from `DroppableMixin` take precedence over defaults in `Page`:

```python
class Dilemma(DroppableMixin, Page):
    fields = dict(
        cooperate=RadioField(
            label="Do you wish to cooperate?",
            choices=[(True, "Yes"), (False, "No")],
        ),
    )

class Negotiation(DroppableMixin, Page):
    fields = dict(
        offer=IntegerField(label="Your offer", min=0, max=100),
    )

class Results(DroppableMixin, Page):
    @classmethod
    def templatevars(page, player):
        return dict(payoff=player.payoff)
```

Each page now inherits the timeout and dropout guard automatically. You still define `fields`, `templatevars`, and any other page-specific methods as usual; they sit alongside the inherited ones without conflict.

### Overriding a single method

If one page needs a different timeout, override just that method. The other two (`timeout_reached` and `before_once`) are still inherited from the mixin:

```python
class LongDecision(DroppableMixin, Page):
    @classmethod
    def timeout(page, player):
        return 120  # more time for this page
```

### Pages that only need the guard

Some pages (like `Results`) do not need a timeout—they have no form to submit. The mixin still works: `timeout` returning a value on a page without fields is harmless. But if you prefer, you can skip the mixin and add just the guard directly:

```python
class Results(Page):
    @classmethod
    def before_once(page, player):
        if player.group.get("dropped"):
            move_to_page(player, Dropped)
```

Either approach is fine. The mixin is most valuable when you have many decision pages that all share the same timeout and dropout logic.

---

Source: https://uproot.science/advanced/uploads.md

# File uploads

uproot supports file uploads from participants using `FileField`. Uploaded files are handled as stealth fields: They are not stored in the database automatically, giving you full control over how to process them.

## Basic file upload

Add a `FileField` to your page:

```python
class Upload(Page):
    fields = dict(
        cv=FileField(label="Please upload your curriculum vitæ."),
    )

    @classmethod
    async def handle_stealth_fields(page, player, data):
        cv = data["cv"]
        contents = await cv.read()
        player.file_name = cv.filename
        player.file_size = len(contents)
```

`FileField` is always treated as a stealth field. The uploaded file is passed to `handle_stealth_fields` as a [Starlette UploadFile](https://www.starlette.io/requests/#request-files) object.

[See the upload example](https://github.com/mrpg/uproot-examples/tree/main/upload)

## The UploadFile object

The uploaded file has these attributes:

| Attribute | Type | Description |
|-----------|------|-------------|
| `filename` | `str` | Original filename |
| `size` | `int` | File size in bytes |
| `content_type` | `str` | MIME type (e.g., `image/png`) |

And this method:

| Method | Returns | Description |
|--------|---------|-------------|
| `await file.read()` | `bytes` | Read the full file contents |

## Storing uploaded files

Since uploads are stealth fields, you decide where to store the data. Common approaches:

### Save to disk

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    photo = data["photo"]
    if photo.size > 0:
        contents = await photo.read()
        from pathlib import Path

        path = Path("uploads") / (
            f"{player.session.name}_{player.name}_{uuid()}"
            f"{Path(photo.filename or '').suffix.lower()}"
        )
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("wb") as f:
            f.write(contents)
        player.photo_path = str(path)
```

### Store metadata only

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    doc = data["document"]
    player.doc_name = doc.filename
    player.doc_size = doc.size
    player.doc_type = doc.content_type
```

## Validating uploads

Return error messages from `handle_stealth_fields` to reject the submission:

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    photo = data["photo"]

    if photo.size == 0:
        return "Please select a file"

    if photo.size > 5_000_000:
        return "File too large (max 5 MB)"

    if photo.content_type not in ("image/png", "image/jpeg"):
        return "Only PNG and JPEG files are accepted"

    contents = await photo.read()
    player.photo_size = len(contents)
```

## Combining uploads with regular fields

You can mix file uploads with regular fields on the same page. Regular fields are saved automatically; the file field goes through `handle_stealth_fields`:

```python
class UploadPage(Page):
    fields = dict(
        participant_name=StringField(label="Your name"),
        photo=FileField(label="Upload a photo"),
    )

    @classmethod
    async def handle_stealth_fields(page, player, data):
        photo = data["photo"]
        if photo.size > 0:
            contents = await photo.read()
            player.photo_name = photo.filename
```

The `participant_name` field saves to `player.participant_name` automatically.
`player.name` is reserved for uproot’s generated player name. The `photo`
field is handled separately.

## Rendering in templates

Use the standard field rendering:

```html+jinja
{% extends "Base.html" %}

{% block main %}
{{ field(form.cv) }}
{% endblock main %}
```

Or render all fields at once:

```html+jinja
{{ fields() }}
```

## Summary

| Feature | Purpose |
|---------|---------|
| `FileField(label=...)` | File upload field |
| `handle_stealth_fields(page, player, data)` | Process uploaded files |
| `await file.read()` | Get file contents as bytes |
| `file.filename` | Original filename |
| `file.size` | File size in bytes |
| `file.content_type` | MIME type |
| Return a string from handler | Validation error message |

---

Source: https://uproot.science/advanced/models.md

# Custom data models

Models provide structured, append-only data storage beyond the standard player/group/session fields. Use them for ordered collections of entries like transaction logs, auction bids, or any data that accumulates over time.

!!! note
    The built-in [chat system](https://uproot.science/multiplayer/chat.md) is built on top of models. For chat functionality, use the chat API directly.

## Creating a model

Create a model in a session, typically in `new_session`:

```python
from uproot.models import create_model

def new_session(session):
    session.offers = create_model(session)
```

`create_model` returns a `ModelIdentifier` that you store on the session (or group) for later use.

## Defining entry types

Define the structure of your entries using the `Entry` metaclass:

```python
from uproot.models import Entry
from uproot.types import PlayerIdentifier

class Offer(metaclass=Entry):
    player: PlayerIdentifier
    price: float
    quantity: int
```

Entry classes are immutable dataclasses. The `id` field name is reserved.

Fields with types `PlayerIdentifier`, `SessionIdentifier`, `GroupIdentifier`, or `ModelIdentifier` are auto-filled when using `add_entry`.

## Adding entries

Use `add_entry` to add an entry to a model. Identifier fields matching the player’s session, group, or player ID are filled automatically:

```python
from uproot.models import add_entry

@live
async def post_offer(page, player, price: float, quantity: int):
    add_entry(player.session.offers, player, Offer, price=price, quantity=quantity)
```

The first argument is the model identifier, the second is the player (used for auto-filling identifier fields), and the third is the entry type. Remaining keyword arguments fill the entry’s fields.

Each entry is timestamped and assigned a UUID.

### Raw entries

To add an entry without auto-filling, use `add_raw_entry`:

```python
from uproot.models import add_raw_entry

add_raw_entry(session.offers, {"event": "started", "detail": "phase 2"})
```

## Retrieving entries

### All entries

```python
from uproot.models import get_entries

entries = get_entries(session.offers, Offer)
for uuid, timestamp, offer in entries:
    print(f"{offer.player} offered {offer.price} at {timestamp}")
```

`get_entries` returns a list of `(UUID, float, entry)` tuples, ordered by time.

### Filtered entries

```python
from uproot.models import filter_entries

# Filter by field values
cheap_offers = filter_entries(session.offers, Offer, price=10.0)

# Filter by predicate
large_offers = filter_entries(
    session.offers, Offer,
    predicate=lambda o: o.quantity > 100,
)

# Filter by entry UUID
specific = filter_entries(session.offers, Offer, id=some_uuid)
```

### Latest entry

```python
from uproot.models import get_latest_entry

uuid, timestamp, latest = get_latest_entry(session.offers, Offer)
```

### Single field

Read a single field from the model’s storage:

```python
from uproot.models import get_field

value = get_field(session.offers, "some_field")
```

## Checking existence

```python
from uproot.models import model_exists

if model_exists(session.offers):
    ...
```

## Example: auction log

A complete example using models to track bids in an auction:

```python
from uproot.models import Entry, create_model, add_entry, get_entries, get_latest_entry
from uproot.types import PlayerIdentifier

class Bid(metaclass=Entry):
    player: PlayerIdentifier
    amount: float

def new_session(session):
    session.auction = create_model(session)

class Auction(Page):
    @live
    async def place_bid(page, player, amount: float):
        add_entry(player.session.auction, player, Bid, amount=amount)

        # Get all bids
        bids = get_entries(player.session.auction, Bid)

        # Return current highest
        if bids:
            _, _, highest = max(bids, key=lambda b: b[2].amount)
            return {"highest": highest.amount, "total_bids": len(bids)}
        return {"highest": 0, "total_bids": 0}
```

## Summary

| Function | Purpose |
|----------|---------|
| `create_model(session)` | Create a new model, returns `ModelIdentifier` |
| `add_entry(mid, player, EntryType, **fields)` | Add entry with auto-filled identifiers |
| `add_raw_entry(mid, entry)` | Add entry without auto-filling |
| `get_entries(mid, EntryType)` | Get all entries as `(UUID, time, entry)` tuples |
| `filter_entries(mid, EntryType, **filters)` | Get filtered entries |
| `get_latest_entry(mid, EntryType)` | Get most recent entry |
| `get_field(mid, field_name)` | Read a field from model storage |
| `model_exists(mid)` | Check if a model exists |

---

Source: https://uproot.science/advanced/settings-forms.md

# Custom settings forms

When an experimenter creates a session, they can adjust the config’s default [session settings](https://uproot.science/building/data.md#session-settings) by editing raw JSON in the admin interface. Raw JSON is flexible, but it is also easy to get wrong: a mistyped key or a string where a list is expected, and the session runs with the wrong parameters.

An app can replace that JSON editor with its own settings form. If your app folder contains a file named `AdminSettings.html`, the admin’s new-session page shows your form instead of the JSON editor whenever a config containing your app is selected.

!!! warning "Super advanced"
    This is one of the most advanced features in uproot. You will write raw HTML, [Jinja](https://jinja.palletsprojects.com/) template code, and [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript) by hand. uproot’s [form fields](https://uproot.science/building/forms.md) are not available here. Most apps are perfectly fine with the JSON editor. This page assumes you have read [Session settings](https://uproot.science/building/data.md#session-settings).

## A minimal example

Suppose your app defines a default number of rounds in its constants class and reads it from the session settings:

```python
class C:
    DEFAULT_ROUNDS = 3
```

Create `AdminSettings.html` next to your app’s `__init__.py`:

```html+jinja
{% set n_rounds = settings.get("n_rounds", C.DEFAULT_ROUNDS) %}

<div class="border rounded p-3" id="{{ editor_id }}">
    <label class="form-label" for="{{ editor_id }}-rounds">Number of rounds</label>
    <input class="form-control" id="{{ editor_id }}-rounds"
        min="1" step="1" type="number" value="{{ n_rounds }}">
</div>

<script>
registerSessionSettingsEditor("{{ editor_id }}", () => {
    const rounds = Number(document.getElementById("{{ editor_id }}-rounds").value);

    if (!Number.isInteger(rounds) || rounds < 1) {
        throw new TypeError("Number of rounds must be a positive integer.");
    }

    return { n_rounds: rounds };
});
</script>
```

Three things happen here:

1. The fragment prefills the input from `settings` (the config’s defaults), falling back to `C.DEFAULT_ROUNDS`, so the form always starts at the values the session would get anyway.
2. The inline script registers a *reader* function for this editor with `registerSessionSettingsEditor`.
3. When the experimenter clicks **Create session**, the reader runs. It validates the input and returns an object whose keys are merged into the session settings. Throwing an error blocks session creation and shows the error message to the experimenter. This is your validation mechanism.

The fragment is not a full page: do not extend `Base.html` or any other template. Write only the form markup and its script. Admin pages load [Bootstrap 5](https://getbootstrap.com/docs/5.3/forms/overview/), so classes like `form-control` and `form-label` work as expected.

## Where uproot looks

- `your_app/AdminSettings.html`: This form is used for every config that includes `your_app`. If a config contains several apps with such a file, all their forms are shown, stacked in the config’s app order.
- `AdminSettings.html` at the project root: This is a project-level form that takes over for *all* configs; app-level fragments are then ignored.
- A fragment that renders to only whitespace is skipped, so you can opt out per config with a Jinja `{% if %}` around the whole file.
- Configs without any fragment keep the plain JSON editor.

## Template variables

Fragments are rendered server-side when the new-session page loads, once per config. They receive:

| Variable | Meaning |
|----------|---------|
| `config` | Name of the config this rendering belongs to |
| `apps` | List of app names in that config |
| `appname` | The app the fragment belongs to (`None` for a project-level fragment) |
| `settings` | The config’s default settings, as declared via `load_config(..., settings={...})` |
| `editor_id`{ .text-nowrap } | Unique ID for this editor—use it as your root element’s `id` and pass it to `registerSessionSettingsEditor` |
| `C` | The app’s constants class (app fragments only) |
| `appstatic` | URL helper for the app’s static files (app fragments only) |

!!! warning
    The same app can appear in several configs, so your fragment may be rendered several times into the same admin page. Never hard-code element IDs; always derive them from `editor_id`, as in the example above.

## The reader function

The reader you register runs when the experimenter submits the form. It receives one argument, an object with

- `config`: the selected config’s name; and
- `settings`: the settings collected so far: the config’s defaults, already merged with the contributions of readers registered before yours.

Return a JSON-serializable object; its keys are merged over those settings. Keys your form does not touch keep their default values. Return `undefined` to contribute nothing. Throw an `Error` with a human-readable message to abort session creation and display the message.

## What the experimenter sees

When a config with a custom form is selected, the **Keep default settings** checkbox starts unchecked and the form is shown, prefilled with the defaults. Checking the box hides the form and uses the config’s defaults unchanged. For such configs, the custom form fully replaces the JSON editor. There is no raw JSON fallback in the UI.

If a fragment raises an error while rendering, or a config’s default settings are not a JSON object, the new-session page shows a red error message for that config instead of a form, and the config falls back to the JSON editor. The full traceback lands in the server log.

## Complete example

The double auction example ships a settings form covering eight settings, including comma-separated integer lists and values that may be either a scalar or one entry per round:

[See AdminSettings.html in the double_auction example](https://github.com/mrpg/uproot-examples/blob/main/double_auction/AdminSettings.html)

---

Source: https://uproot.science/running/admin.md

# The admin interface

The admin interface lets you create and manage sessions, monitor participant progress, and intervene during experiments.

## Accessing the admin

The admin is available at `/admin/` on your server. During development:

```
http://127.0.0.1:8000/admin/
```

### Authentication

uproot lets you sign in with a secret login link or a password. The default setup in [`main.py`](https://uproot.science/getting-started/project-structure.md#the-mainpy-file) supports both:

```python
upd.ADMINS["admin"] = upd.auto_login()
```

When `UPROOT_ADMIN_PASSWORD` is unset or empty and `admin` is the only admin account, uproot prints an auto-login URL when the server starts:

```
Auto login:
     http://127.0.0.1:8000/admin/login/#aBcDeFgHiJkL...
```

The link authenticates you as `admin` using a cryptographically random secret token. You can use it locally or in production. Keep the link and server logs private, and use HTTPS for [public deployments](https://uproot.science/running/deployment.md).

#### Password login

For password login, set `UPROOT_ADMIN_PASSWORD`, an **environment variable** (a named startup setting). Use your hosting provider’s secret settings or a `.env` file in the project directory:

```dotenv
UPROOT_ADMIN_PASSWORD='replace-with-a-long-random-password'
```

Replace the example value with a long, unique password. uproot rejects passwords shorter than five characters at startup. Keep `.env` out of Git and restrict access to it. uproot loads it automatically using [python-dotenv](https://bbc2.github.io/python-dotenv/#getting-started); existing environment variables take precedence.

Restart the server, then sign in at `/admin/` as `admin`. A non-empty password replaces the login link; `main.py` stays unchanged.

#### Multiple admin accounts

Adding or renaming accounts disables the printed login link. Give each account its own password by replacing the single-account assignment in `main.py`:

```python
import os

upd.ADMINS["admin"] = os.environ["UPROOT_ADMIN_PASSWORD"]
upd.ADMINS["researcher"] = os.environ["UPROOT_RESEARCHER_PASSWORD"]
```

[`os.environ`](https://docs.python.org/3/library/os.html#os.environ) reads environment variables. Set both variables to different, long passwords as above. A missing variable stops startup with a `KeyError`; set it and restart. Use the first assignment alone to require password login for a single account.

## Dashboard

The dashboard (`/admin/dashboard/`) shows an overview of active sessions and rooms. From here you can navigate to any session or room.

## Creating sessions

Navigate to **Sessions** → **New session** to create a session:

1. **Config:** Select which experiment config to run.
2. **Number of players:** How many player slots to create.
3. **Settings (JSON):** Optional JSON object for session settings (accessible via `session.settings`); apps can replace this JSON editor with a [custom settings form](https://uproot.science/advanced/settings-forms.md).
4. **Custom session name:** Optional (auto-generated if omitted).
5. **Custom player names:** Optional (auto-generated if omitted).
6. **Simulate responses:** If enabled, the app’s `simulate.js` file runs on every player page load (see [App testing](#app-testing) below).

After creation, each player gets a unique URL:

```
https://your-server.com/p/{session_name}/{player_name}/
```

## Session management

The session detail page (`/admin/session/{sname}/`) is your control center during an experiment.

### Player monitor

The session page shows all players with their current status:

- **current page:** which page each player is on;
- **online status:** whether the player is currently connected;
- **progress:** how far through the experiment each player is.

### Player actions

Select one or more players and use these actions:

| Action | What it does |
|--------|-------------|
| **Advance** | Move selected players forward one page |
| **Revert** | Move selected players back one page, but not into an earlier round or app (see below) |
| **Move to end** | Skip selected players to the end of the experiment |
| **Reload** | Force a page reload in selected players’ browsers |
| **Send message** | Display a message on selected players’ screens |
| **Mark as dropout** | Flag selected players as dropouts |
| **Redirect** | Send selected players to an external URL |
| **Set fields** | Set arbitrary field values on selected players |
| **Group/ungroup** | Manually create or dissolve groups |
| **Run `new_player`** | Re-run the `new_player` callback for selected players |

!!! note "Reverting stops at the start of rounds and apps"
    **Revert** cannot take players back into an earlier round of [`Rounds` or `Repeat`](https://uproot.science/building/operators.md#rounds), into an earlier app, or out of the end of the experiment. uproot does not count `player.round` back down or switch `player.app` back, so anything players entered after such a revert would be saved under the wrong round or app. If any selected player would cross such a boundary, uproot shows “The action failed” and moves none of the selected players. The server log names the player and the boundary.

### Session controls

| Control | What it does |
|---------|-------------|
| **Toggle active** | Pause/resume the session (inactive sessions reject new page loads) |
| **Toggle testing** | Mark the session as a test run (useful for filtering data later) |
| **Update description** | Add a note about the session |
| **Update settings** | Modify session settings (JSON) |
| **Run `new_session`** | Re-run the `new_session` callback |

### Data browser

Click **View data** on the session page to browse all stored data in a table view. Each player’s fields are shown with their current values, timestamps, and the code location that set each value. You can filter the table by app, by [block](https://uproot.science/building/operators.md#blocks), and by round.

### Downloading data

Click **Download data** on the session page to download the session’s complete data as a single ZIP archive. See [Exporting data](https://uproot.science/running/export.md) for what is inside and how to analyze it.

### Page times

Download a CSV of page visit times showing when each player entered and left each page (available under *Other data* on the **Download data** page). Useful for measuring response times.

### Digest

If your app defines `digest(session)`, the digest view shows its output. A list of rows is a common return value. Pair it with `AdminDigest.html` in the app directory: that file must contain **only** a `main` block (do not extend `Base.html`). Lists and other non-dict values are available as `data`:

```html+jinja
{% block main %}
<p>{{ data | length }} groups so far.</p>
{% endblock main %}
```

If `digest()` returns a dictionary, its keys become template variables instead. For example, `return {"groups": rows}` makes the rows available as `groups`.

[See the prisoners_dilemma_repeated example for a digest implementation](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_repeated)

### Pipeline

If your app defines `pipeline(session, data)`, the admin can run it from the session page. Return a list of dictionaries to get a downloadable table (one row per dict):

```python
def pipeline(session, data=None):
    rows = []
    for group in session.groups(app=__name__):
        p1, p2 = group.players
        p1_data = p1.within(app=__name__)
        p2_data = p2.within(app=__name__)
        rows.append(dict(
            group=group.name,
            p1=p1.name,
            p1_cooperate=p1_data.get("cooperate"),
            p2=p2.name,
            p2_cooperate=p2_data.get("cooperate"),
        ))
    return rows
```

[`player.within()`](https://uproot.science/building/results.md#using-playerwithin) selects values recorded in this app. Using `.get()` keeps the pipeline usable before every participant has submitted a decision.

uproot always calls `pipeline(session=session, data=data)`. The admin (or [REST API](https://uproot.science/reference/admin-api.md)) can post JSON that is passed through as `data`; without a JSON body, `data` is `None`. The admin provides a built-in JSON input for this. To show app-specific instructions or status above the pipeline buttons, add `AdminPipeline.html` with a `main` block only.

[See pipeline in the prisoners_dilemma_fancy example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma_fancy) · [prediction_market example](https://github.com/mrpg/uproot-examples/tree/main/prediction_market)

## Managing rooms

Navigate to **Rooms** to see all rooms, separated into open and closed sections. See [Rooms](https://uproot.science/running/rooms.md) for details on room configuration.

From a room’s admin page you can:

- see which participants are waiting in the room (with live label tracking);
- create a session with pre-assigned player slots;
- edit room settings (config, labels, capacity) when no session is associated.

When a session is associated, the room’s admin page shows:

- **room status:** open/closed, capacity, and join mode (free join or restricted);
- **close room**/**Reopen room:** stop or resume accepting new participants without affecting the running session (see [Closing and reopening a room](https://uproot.science/running/rooms.md#closing-and-reopening-a-room));
- **disassociate:** unlink the session, so the room can be reused.

## Admin chat

The admin chat lets experimenters communicate directly with individual participants during a running session. Open it from the session detail page by clicking the chat icon next to a player.

Features:

- **Per-player channels:** Each participant gets a private conversation with the experimenter.
- **Enable/disable replies:** Control whether the participant can write back or only receive messages.
- **Participant-side widget:** A floating chat button appears on the participant’s screen when the admin sends a message.
- **Real-time updates:** Messages appear instantly on both sides via WebSocket.

Participants see a small button in the bottom-right corner. Messages from the experimenter appear in a pop-up chat window. Whether the participant can reply is controlled by the toggle in the admin view.

!!! note
    Admin chat channels are created automatically the first time the admin sends a message to a participant. No setup is required in your app code.

## App testing

uproot supports automated page interactions via a `simulate.js` file in each app. When a session is created with the **Simulate responses** option enabled, `simulate.js` runs on every player page load.

This lets you verify that your experiment works end-to-end without manually clicking through as a participant.

The fastest way to run a simulated session is from the command line: [`uproot start`](https://uproot.science/reference/cli.md#uproot-start) with the `--simulate` flag starts the server with a room whose session has simulation enabled.

```console
uv run uproot start myconfig --simulate
```

Open the printed room link and watch the app play itself.

### Writing simulate.js

When you create a new project with `uproot setup`, a template `simulate.js` is generated automatically. Register a handler with `uproot.simulate.on()` and use the simulation helpers to fill fields and submit:

```javascript
uproot.simulate.on("my_app/Decision", (sim) => {
    sim.choose("choice", sim.random(["A", "B"])).submit();
});
```

The page key passed to `uproot.simulate.on()` is `"app_name/PageClassName"`.

| Helper | What it does |
|--------|----------------|
| `sim.fill(name, value)` or `sim.fill({age: 30, name: "Ada"})` | Set one or several input values |
| `sim.choose(name, value)` | Select a radio button or dropdown option |
| `sim.check(name)` / `sim.uncheck(name)` | Toggle a checkbox |
| `sim.oneOf(name, values)` | Choose a random value from an array |
| `sim.chooseAnyRadio()` | Pick a random radio button on the page |
| `sim.random(array)` | Return a random element |
| `sim.integer(min, max)` | Return a random integer in `[min, max]` |
| `sim.submit()` | Submit the page |
| `sim.value(name)` | Read the current value |
| `sim.element(name)` / `sim.field(name)` | Find a DOM element |

The setters (`fill`, `choose`, `check`, `uncheck`, `select`, `oneOf`, and `chooseAnyRadio`) and `submit` return `sim`, so you can chain them. Lookup helpers such as `value`, `element`, and `field` return the requested value or DOM element instead. Pages with no fields can just call `sim.submit()`.

!!! warning
    Simulation is session-level and permanent. Once a session is created with simulation enabled, it cannot be disabled for that session. Create a new session without the option to run without simulation.

[See simulate.js in the prisoners_dilemma example](https://github.com/mrpg/uproot-examples/tree/main/prisoners_dilemma)

## Server status

The status page (`/admin/status/`) shows:

- database size;
- installed package versions;
- environment variables;
- active authentication sessions.

### Announcements

The status page also has a button that checks whether the uproot developers have posted important announcements, such as security fixes, for your version. Check regularly while you run studies. Once the server has been running for a minute, the dashboard and status page remind you if you have never checked or your last check was over a week ago. Click “Dismiss nudge” on the status page to silence the reminder for ten years.

uproot never contacts uproot.science on its own—only when you click this button, click “Submit praise” in the footer, or run [`uproot announcements`](https://uproot.science/reference/cli.md#uproot-announcements). Participants’ browsers never contact it at all (see [Why uproot?](https://uproot.science/why-uproot.md#your-participants-talk-only-to-your-server)).

If your server must never make outside connections, for example in an isolated lab network, switch these features off in `main.py`:

```python
upd.UPSTREAM = False
```

The server then never contacts uproot.science, and the announcement and praise buttons and reminders disappear from the admin interface. You then need to check for announcements some other way, for instance by visiting [uproot.science](https://uproot.science/) now and then.

## Database dump

Download a complete database dump from `/admin/dump/`. This is equivalent to running `uproot dump` from the CLI.

## Summary

| Page | URL | Purpose |
|------|-----|---------|
| Dashboard | `/admin/dashboard/` | Overview of sessions and rooms |
| Sessions | `/admin/sessions/` | List all sessions |
| New session | `/admin/sessions/new/` | Create a session |
| Session detail | `/admin/session/{sname}/` | Monitor and control a session |
| Admin chat | `/admin/session/{sname}/chat/` | Chat with individual participants |
| Data browser | `/admin/session/{sname}/viewdata/`{ .text-nowrap } | Browse session data |
| Data download | `/admin/session/{sname}/data/` | Download session data as a ZIP archive |
| Rooms | `/admin/rooms/` | List all rooms |
| Status | `/admin/status/` | Server information |

---

Source: https://uproot.science/running/rooms.md

# Sessions and rooms

Sessions and rooms are the two concepts for managing how participants access your experiment.

## Sessions

A **session** is a running instance of an experiment config, containing players and their data. Each session has a unique session name (a short random string by default) and is associated with exactly one config.

### Creating a session directly

From the admin interface, navigate to **Sessions** and create a new session by selecting a config and specifying the number of players. This gives you a session with pre-created player slots that you can distribute manually.

Each pre-created player gets a unique URL of the form:

```
https://your-server.com/p/{session_name}/{player_name}/
```

You can share these URLs with participants individually. This approach works well for small experiments or when you want to assign specific participants to specific slots.

### Session initialization

A session is initialized the first time a player loads their first page. Initialization runs the `new_session` callback in each app (if defined), followed by `new_player` for that player. If you created the session manually with pre-created players, you can also trigger initialization explicitly via the admin API using the `run_new_session` and `run_new_player` endpoints.

## Rooms

A **room** is a gateway that controls how participants enter a session. Instead of distributing individual player URLs, you can give all participants the same room URL:

```
https://your-server.com/room/{room_name}/
```

The room handles everything: waiting for the experiment to start, validating credentials, creating player slots, and redirecting participants to their session.

### Creating a room

Navigate to **Rooms** in the admin interface and create a new room. A room has several optional settings:

- **Config**: Which experiment config to run.
- **Labels**: Access codes that restrict who can join.
- **Capacity**: A maximum number of players.
- **Open**: Whether the room is currently accepting participants.

You can also create a room from code in your project’s `main.py` by appending to `DEFAULT_ROOMS`:

```python
import uproot.deployment as upd
from uproot.rooms import room

upd.DEFAULT_ROOMS.append(
    room(
        "my_room",
        config="my_experiment",
        labels=["ALPHA", "BRAVO", "CHARLIE"],
        capacity=20,
        open=True,
    )
)
```

Rooms defined this way are created automatically when the server starts.

### Quick rooms from the command line

During development, creating a room by hand just to test a config is tedious. The [`uproot start`](https://uproot.science/reference/cli.md#uproot-start) command does it in one step:

```console
uv run uproot start my_experiment
```

This creates an open room named `quick1` (then `quick2`, and so on), creates a session for the config, starts the server, and prints the room URL. Open the URL in one browser tab per participant. Add `--simulate` to enable [simulated responses](https://uproot.science/running/admin.md#app-testing) for the session.

Quick rooms are ordinary rooms: they appear under **Rooms** in the admin, and everything on this page applies to them.

### Room lifecycle

A room acts as a finite-state machine with two main states:

**Waiting (closed)**

The room is in waiting state when any of the following are true:

- `open` is `False`
- No config is associated with the room
- Both of the above

Participants who visit a waiting room see a “Please wait” page. This page maintains a WebSocket connection to the server so it can advance automatically when the room opens. Participants do not need to refresh.

**Accepting (open)**

When `open` is `True` and a config is set, the room begins accepting participants. Two things happen:

1. If no session exists yet, one is automatically created from the room’s config when the first participant arrives.
2. Participants are added to the session (subject to label and capacity checks).

After joining, participants are redirected to their player URL and begin the experiment.

### Starting a session in a room

From a room’s admin page, you can start a session in two ways:

**Let participants join on their own.** Set the room to open with a config. A session is created automatically when the first participant arrives, and subsequent participants join the same session. This is the most common approach.

**Pre-create a session with players.** Use the “Create session” form on the room’s admin page. Specify a config and the number of players. The session is created with player slots, the room opens, and waiting participants are notified via WebSocket. Pre-created player slots are claimed on a first-come, first-served basis.

When a session is started in a room (either way), the `r.start(roomname)` signal fires, which wakes all WebSocket connections on the waiting page. Participants’ browsers automatically submit the join form.

### Closing and reopening a room

You can close a room at any time to stop accepting new participants—even while a session is active. Closing a room does **not** affect the running session or its current players; it only prevents new participants from joining.

From the room’s admin page, use the **Close room** button. To resume accepting participants into the same session, use **Reopen room**.

This is useful when:

- You have enough participants and want to prevent latecomers from joining.
- You need to temporarily pause enrollment while checking on the experiment.
- You want to control exactly when new participants can enter.

Via the API, use [`PATCH /admin/api/v1/rooms/{roomname}/open/`](https://uproot.science/reference/admin-api.md#patch-adminapiv1roomsroomnameopen) with `{"open": false}` to close or `{"open": true}` to reopen.

#### Closing vs. capacity

Closing a room and setting a capacity are two different mechanisms:

- **Capacity** limits how many players can join. Once the limit is reached, new participants see a “Room full” page. The room remains open and will accept participants again if capacity is freed up or increased.
- **Closing** shuts the door entirely. No new participants can join regardless of available capacity. Participants who visit a closed room see a “Please wait” page.

Use capacity when you want a fixed maximum group size. Use closing when you want manual control over *when* participants can enter, independent of how many have already joined.

### Disassociating and reusing rooms

A room can only have one session at a time. To reuse a room for a new session, first **disassociate** the current session. This unlinks the session from the room and resets the room’s state so new participants can wait again.

Room settings (config, labels, capacity) can only be edited when no session is associated.

### Labels (access codes)

Labels normally restrict room access to a predefined set of participants. Each participant must enter a valid label (access code) to join. You can also require a code without defining the allowed values in advance.

```python
upd.DEFAULT_ROOMS.append(
    room(
        "my_room",
        config="my_experiment",
        labels=["ALPHA", "BRAVO", "CHARLIE"],
    )
)
```

When labels are set:

- Participants see a “Welcome” page with an access code input field.
- The entered label is validated against the room’s label list.
- Invalid labels are rejected with an error message.
- Labels must use only the characters `A-Za-z0-9-._` and be at most 128 characters long.

Pass `labels=[]` (an empty list) to accept **any** non-empty access code, without listing codes in advance.

To load codes from a text file (one label per line; `#` starts a comment), use `from_file`:

```python
from uproot.rooms import room, from_file

upd.DEFAULT_ROOMS.append(
    room("lab", config="my_experiment", labels=from_file("labels.txt"))
)
```

After entering a valid label, participants wait for the room to open (if it is not already open). Once the room opens, they are automatically redirected.

If a participant enters a label that has already been used by another player in the session, they are redirected to that existing player’s URL. This means a participant can rejoin using the same label if they lose their connection.

### Eagerly accepted labels

*If* your room does not require labels, uproot will still eagerly accept labels that are provided using the `?label=` URL parameter. You could use that feature as follows:

```
https://your-server.com/room/{room_name}/?label=MY_LABEL
```

This is useful for physical labs (where `MY_LABEL` may be `01` to `32`, the number of cubicles), and online platforms, such as [Prolific](https://prolific.com/) IDs. For Prolific, a room with `labels=[]` works even better. See [Running on Prolific](https://uproot.science/running/prolific.md).

### Labels and capacity

When a non-empty label list is set but no explicit capacity is configured, the room’s effective capacity equals the number of labels. This means each label can be used by exactly one participant. The special `labels=[]` mode has no implicit capacity; set `capacity=` as well if you need a limit.

If you set both labels and an explicit capacity, the capacity value takes precedence. For example, you could have 100 labels but a capacity of 50, meaning only the first 50 valid labels will be accepted.

### Capacity

Capacity sets the maximum number of players that can join a room’s session. When the session is full, new participants see a “Room full” page.

```python
upd.DEFAULT_ROOMS.append(
    room(
        "my_room",
        config="my_experiment",
        capacity=30,
    )
)
```

### How capacity is enforced

A participant can join a room’s session if any of the following conditions are true:

1. **The room is freejoin:** `labels` is `None` or `[]`, and no capacity is set, so participants can join without limit.
2. **The session has room:** The current number of players is below the capacity.
3. **A free slot exists:**: A pre-created player slot that has not been claimed yet. Free slots bypass the capacity check, so pre-created players can always be claimed.

This means capacity primarily controls *growth*: It prevents new player slots from being created beyond the limit. It does not prevent participants from claiming pre-created slots, even if the number of pre-created slots exceeds the capacity.

### Capacity and manual session creation

When you create a session in a room with pre-created players, you can check the **"Set room capacity to number of players"** option. This sets the room’s capacity equal to the number of pre-created players, preventing any additional participants from joining beyond the pre-created slots.

Without that option, the room’s original capacity setting (if any) remains. Pre-created slots can always be claimed, but new slots are only created up to the capacity.

This implies: if you create a session with *n* players, but uncheck “Set room capacity to number of players,” your session can keep growing until the room’s capacity is reached. If the capacity is infinite, the session will be able to grow indefinitely. **Rooms are the only method to create new player slots in a session.**

### Freejoin rooms

A room with the default `labels=None` and no capacity is a **freejoin** room. There is no limit on how many participants can join, and every visitor gets a new player slot in the session. This is useful for large-scale experiments where you want to accept as many participants as possible.

The `labels=[]` mode with no capacity is also unlimited, but each visitor must enter a non-empty code. Reusing a code returns to the player slot that already has that code.

### The room WebSocket

When participants are on the waiting page, the browser opens a WebSocket connection to the server. This connection serves two purposes:

1. **Instant notification**: When the room opens, a `RoomStarted` event is sent over the WebSocket, and the browser automatically submits the join form. Participants do not need to refresh.
2. **Presence tracking**: The server tracks which participants are online in the room’s waiting area. The admin can see this on the room’s admin page.

The WebSocket connection uses a heartbeat mechanism to stay alive. If a participant closes the page, they are marked as offline.

---

Source: https://uproot.science/running/prolific.md

# Running on Prolific

[Prolific](https://www.prolific.com/) is a platform for recruiting online participants. Prolific sends each participant to a URL you provide, and when they finish, they come back to Prolific with a *completion code* that tells Prolific to pay them. uproot needs very little setup for this: one room, one URL parameter, and one link at the end of your experiment.

This page assumes you have read [Sessions and rooms](https://uproot.science/running/rooms.md) and know how to create a session in [the admin interface](https://uproot.science/running/admin.md).

## The setup at a glance

1. Create a room with `labels=[]` and no capacity, in the admin interface or in `main.py`.
2. In the admin, create a session in that room with **0 players** and put your completion code in the session settings. This opens the room.
3. Give Prolific the room URL, with the participant’s Prolific ID in `?label=`.
4. End your experiment with a “Finish study” link that sends participants to Prolific with the completion code.

Each step is explained below.

## Step 1: Create the room

You can create the room in the admin interface or in your project’s `main.py`. Both give you the same room.

=== "Admin interface"

    Go to **Rooms** in the admin and create a new room:

    - **Room name:** for example, `my_study`.
    - **Associate with a particular config:** check this and select your config.
    - **Start this config immediately:** uncheck this. It is checked by default (see below for why).
    - **Use labels/Access Codes:** check this, but leave the **Labels** box **empty**.
    - **Limit capacity of resulting session:** leave this unchecked.

=== "main.py"

    ```python
    upd.DEFAULT_ROOMS.append(
        room(
            "my_study",
            config="my_experiment",
            labels=[],
            open=False,
        )
    )
    ```

    Rooms defined this way are created automatically when the server starts.

An empty label list (`labels=[]`) means “every participant needs a label, and any non-empty label is accepted.” No capacity is set, so the room has no upper limit. Every participant who arrives gets a new player slot in the session.

The room starts out closed. You open it in Step 2 by creating the session. Until then, anyone who visits the room URL sees a waiting page. If the room were already open, the first visitor would make uproot create a session automatically, without the completion code you are about to enter.

### Why `labels=[]` and not the default

A room with the default `labels=None` would also accept everyone, and it would also store a `?label=` it finds in the URL (see [Eagerly accepted labels](https://uproot.science/running/rooms.md#eagerly-accepted-labels)). But `labels=[]` is the better choice for Prolific, for two reasons:

- **Every player gets a Prolific ID.** If the label is missing from the URL (for example, because the participant copied the link by hand), uproot shows a page that asks for an access code instead of letting the participant in anonymously. You never end up with a player you cannot match to a Prolific submission.
- **Participants cannot start twice.** If someone opens the study link again—after closing the tab, after a crash, or on a different device—uproot sees the same label and sends them back to their existing player slot. They continue where they left off instead of starting over in a fresh slot.

Prolific IDs consist only of letters and digits, so they are valid labels (labels may contain `A-Za-z0-9-._`, up to 128 characters).

### Why no capacity

It is tempting to set `capacity` to the number of places in your Prolific study. Do not do that. On Prolific, participants regularly return a study or time out, and Prolific then offers their place to someone new. The returned participant’s player slot still counts toward the room’s capacity, so a capacity equal to your number of places would lock out the replacements. Let Prolific control how many people take part. If you need to stop admissions, [close the room](https://uproot.science/running/rooms.md#closing-and-reopening-a-room) instead.

## Step 2: Create a session with 0 players

On the room’s admin page, use the “Create session” form:

- **Number of players:** `0`. Player slots are created as participants arrive, so you do not need any in advance.
- **Keep default settings:** uncheck this. It is checked by default, and the **Settings** box appears only once you uncheck it.
- **Settings:** your completion code, which you copy from your study’s page on Prolific:

    ```json
    {"completion_code": "C1ABC2DE"}
    ```

- **“Set room capacity to number of players”:** **uncheck** this. It is checked by default.

Creating the session opens the room, so participants can join from now on.

!!! warning "Do not check “Set room capacity to number of players”"
    With 0 players, this option sets the room’s capacity to 0. Every participant then sees a “Room full” page, and nobody can join. If this has already happened, go to the room’s admin page, open **Actions**, uncheck **Limit capacity**, and click **Set**. This removes the limit while the session keeps running. You can do the same via the [admin REST API](https://uproot.science/reference/admin-api.md#patch-adminapiv1roomsroomnamecapacity).

Why create the session by hand instead of letting the room create one automatically? Because the admin form lets you enter [session settings](https://uproot.science/building/data.md#session-settings). Keeping the completion code in the session settings, rather than in your code, has two advantages: the code never ends up in a public Git repository, and you can run several Prolific studies (each with its own code) from the same project. If you mistype the code, fix it on the session page with **Change settings**.

If you prefer, you can instead give the code as a default via [`load_config(..., settings={"completion_code": "C1ABC2DE"})`](https://uproot.science/getting-started/project-structure.md) and let the room create the session automatically when the first participant arrives.

## Step 3: Give Prolific the study URL

In your Prolific study, enter the room URL as the study URL, and choose the option to [record Prolific IDs via URL parameters](https://researcher-help.prolific.com/en/articles/445178-what-survey-experimental-software-is-compatible-with-prolific). Prolific suggests a parameter named `PROLIFIC_PID`. Rename it to `label`:

```
https://your-server.com/room/my_study/?label={{%PROLIFIC_PID%}}&STUDY_ID={{%STUDY_ID%}}&SESSION_ID={{%SESSION_ID%}}
```

Prolific replaces `{{%PROLIFIC_PID%}}` with each participant’s ID. uproot reads only `label`. The `STUDY_ID` and `SESSION_ID` parameters are harmless, but uproot does not store them, so you can also drop them.

Open the URL yourself with a made-up label, such as `?label=TEST1`, to check that everything works before you publish the study. Your test player appears in the session like any other player.

## Step 4: Send participants back to Prolific

[Prolific’s completion URL](https://researcher-help.prolific.com/en/articles/445178-what-survey-experimental-software-is-compatible-with-prolific) has the form `https://app.prolific.com/submissions/complete?cc=YOUR_CODE`. Put a link to it on the last page of your experiment:

```html+jinja
{% extends "Base.html" %}

{% block main %}
<p>Thank you for taking part!</p>

<p>
    <a class="btn btn-primary" href="https://app.prolific.com/submissions/complete?cc={{ session.settings.get('completion_code', 'NOCODE') | urlencode }}">Finish study</a>
</p>
{% endblock main %}

{% set buttons = False %}
```

The participant clicks the link, lands on Prolific, and their submission is marked as complete.

Note `.get('completion_code', 'NOCODE')`: if you forget to set the code, or test the app on its own (via its `~app` config, which has [no settings](https://uproot.science/getting-started/project-structure.md)), the page still works. Prolific then receives the code `NOCODE`, which it does not recognize, so you notice the mistake and can review those submissions by hand.

!!! note "Put the link on the last page"
    The participant should click “Finish study” only after uproot has saved all their data. Put the link on a page after your last form, not on a page with form fields, because a link does not submit the form.

## Working with the data

Every player’s Prolific ID is stored as their `label`. When you [export your data](https://uproot.science/running/export.md), the `label` column lets you [match your experimental data to Prolific’s demographic data](https://researcher-help.prolific.com/en/articles/445210-matching-data-between-prolific-and-your-external-study-software).

The label check works within one session only. If the same person could take part in two of your studies (for example, a pilot and the main study, each with its own room and session), use [Prolific’s filter that excludes participants of previous studies](https://researcher-help.prolific.com/en/articles/445164-how-can-i-prevent-specific-participants-from-accessing-my-study).

## Advanced: bonus payments and screen-outs

The two recipes below need a bit more code. They are good tasks for a coding agent (such as Claude Code or Codex): describe your experiment and point the agent at this page.

### Bonus payments

[Prolific’s bulk bonus form](https://researcher-help.prolific.com/en/articles/445233-how-do-i-send-bonus-payments) takes one line per participant: their Prolific ID and bonus amount, separated by a comma. uproot stores the Prolific ID as `player.label`, so you do not need anything from Prolific to build these lines. Suppose your app saves each participant’s bonus as `player.bonus`. A [pipeline](https://uproot.science/running/admin.md#pipeline) exports the participant IDs and amounts:

```python
def pipeline(session, data=None):
    rows = []

    for player in session.players:
        bonus = player.within(app=__name__).get("bonus")

        if player.label and bonus:
            rows.append(dict(prolific_id=player.label, amount=f"{bonus:.2f}"))

    return rows
```

Run the pipeline from the session page in the admin and download its table. In Prolific’s **Bulk actions** → **Bulk bonus payment**, paste one line per bonus as `<prolific_id>,<amount>`, without a header row. Amounts are in your study’s currency.

### Screen-outs

Sometimes participants should leave early, for example because they do not consent or fail an attention check. Prolific lets you define [several completion codes per study](https://researcher-help.prolific.com/en/articles/445170-custom-completion-codes), each with its own action (such as approving the submission, or asking the participant to return it). Put all codes in the session settings:

```json
{"completion_code": "C1ABC2DE", "screenout_code": "C9XYZ8WV"}
```

If your app saves the outcome as `player.passed_attention_check`, pick the right code on the final page:

```html+jinja
{% if player.passed_attention_check %}
    {% set cc = session.settings.get("completion_code", "NOCODE") %}
{% else %}
    {% set cc = session.settings.get("screenout_code", "NOCODE") %}
{% endif %}

<a class="btn btn-primary" href="https://app.prolific.com/submissions/complete?cc={{ cc | urlencode }}">Finish study</a>
```

To send screened-out participants to the final page right away, skip the remaining pages with a [`show`](https://uproot.science/building/pages.md) method, or use [`move_to_page`](https://uproot.science/reference/api.md#move_to_page) to send them to that page.

---

Source: https://uproot.science/running/export.md

# Exporting data

uproot stores every piece of data your experiment collects in an [append-only log](https://uproot.science/building/data.md). Nothing is ever overwritten or lost. When you download your data, you do not have to pick and choose: you always get a single ZIP archive (the *data briefcase*) that contains your session’s complete data in all key formats, ready for Excel, R, or Python.

This page shows you how to download that ZIP and how to analyze its contents. If you want to automate exports, work with the REST API, or analyze the database directly in Python, see [Advanced data access](https://uproot.science/running/export-advanced.md).

## Downloading your data

On a session’s page in the admin interface, click **Download data**. You will see a short form:

- **Latest × Field(s):** This adds an extra format with one row per player per grouping you specify (for example, one row per player per `round`). You can uncheck this if your study does not make use of multiple apps or rounds. Leave it checked if you are not sure; this format encompasses the “latest” format.
- **File type:** **CSV** (opens in Excel and every stats package) or **JSONL** (preserves data types; see [CSV or JSONL?](#csv-or-jsonl) below).
- **Apply reasonable filters:** This is checked by default. It cleans up uproot-internal fields so your data focuses on what your experiment actually collected.

Click **Download** and you will receive one ZIP file named after the session and the current date, for example `mysession_2026-07-04_1412.zip`.

## What’s inside the ZIP

Unpacking the ZIP gives you a single folder named after your session:

```
mysession/
├── README.txt            ← explains the contents, right inside the ZIP
├── DATA_DICTIONARY.json  ← defines the !-prefixed columns
├── page_times.csv        ← when each player entered/left each page
├── SHA256SUMS            ← checksums for verifying all other files
├── latest/
│   ├── player.csv
│   ├── group.csv
│   └── session.csv
├── sparse/
│   ├── player.csv
│   ├── group.csv
│   └── session.csv
└── ultralong/
    ├── player.csv
    ├── group.csv
    └── session.csv
```

If you check **Latest × Field(s)**, there is one more folder, named after your grouping variable(s). With the default grouping variables (`app`, `block`, and `round`), it is `latest_by_app_block_round/`.

Each subfolder contains the same data in a different *format* (explained below). Within each format, the data is split into one file per kind of storage:

| File | Contains |
|------|----------|
| `player.csv` | Data stored on players; usually what you want |
| `group.csv` | Data stored on groups (only if your session has groups) |
| `session.csv` | Session-level data |
| `model.csv` | [Custom data models](https://uproot.science/advanced/models.md) (only if your apps use them) |

Each file only has columns for fields that actually occur in that kind of storage, so player files are not cluttered with session-level columns and vice versa.

!!! tip "In a hurry?"
    **`latest/player.csv`** or **`latest_by_…/player.csv`** is the file most people want: one row per participant, one column per field, showing each field’s final value (at the end of each round when grouping by `round`).

## The three formats

Every briefcase contains the same underlying data at three levels of detail:

### “Latest”: one row per player

One row per storage (e.g., per player), showing the most recent value of every field. This is the most compact format and what you will typically use for analysis if your study does not involve multiple apps or multiple rounds.

#### “Latest × Field(s)”: one row per player per level of your grouping variable(s)

If you enable **Latest × Field(s)**, the `latest_by_…/` folder contains one row per player *per combination of your grouping variable(s)*—for example, each participant’s state at the end of each round. This is ideal for panel-style analyses.

By default, uproot groups by `app`, `block`, and `round`. This gives you one row per participant for every round of every app and [block](https://uproot.science/building/operators.md#blocks). Apps without rounds get one row each, showing the participant’s state at the end of that app.

!!! note
    Grouped snapshots include every field known at that point in time (except `page_order` with [reasonable filters](#reasonable-filters)). The grouping variables determine *when* snapshots are taken, not which columns they carry. Fields set before the grouping variable appear in the output as expected.

### “Sparse”: one row per change, one column per field

Each row represents a single change. Every field has its own column, and only the field that changed at that moment is filled in. This produces a wide but mostly empty table. Useful when you want to reconstruct *how* values evolved but still prefer one column per field.

### “Ultralong”: the raw event log

One row per field change, in full detail. Every time a field’s value was set, there is a row recording what changed, when, and where in the code. This is the most detailed format and preserves the complete history. Use it for temporal analyses, audits, or debugging.

## Understanding the columns

Columns that start with `!` come from uproot itself; they sort to the front and can never clash with your own field names. In `ultralong` files you will find all of them:

| Column | Description |
|--------|-------------|
| `!storage` | Which storage this row belongs to (e.g., `player/mysession/9wpsj`) |
| `!field` | Field name |
| `!time` | Unix timestamp of the change |
| `!seq` | Sequence number of the change (for exact ordering) |
| `!context` | Code location that made the change |
| `!unavailable`{ .text-nowrap } | Whether this row marks a deletion (a tombstone) |
| `!data` | The value |

`latest` files just have `!storage`, `!time`, and `!seq` (the time and sequence of the most recent change reflected in the row), followed by one column per field.

The full machine-readable definitions live in `DATA_DICTIONARY.json` inside the briefcase.

The last part of `!storage` is the participant’s uproot name. For instance, `player/mysession/9wpsj` is participant `9wpsj` in session `mysession`.

## Reasonable filters

The **Apply reasonable filters** option (on by default) cleans up uproot-internal fields:

- It renames `_uproot_group` to `group` (with values like `group/mysession/g1`, matching the `!storage` column of `group.csv`; this is handy for merging, see below). When a participant leaves a group—for example, when they reach the next app’s `GroupCreatingWait` page—`group` becomes empty until they join a new one.
- It renames `_uproot_session` to `session`.
- In `group.csv` and `session.csv`, it renames `_uproot_players` to `players`: a list of the members, with values like `player/mysession/9wpsj`.
- In `session.csv`, it renames `_uproot_groups` to `groups`: a list of the session’s groups, with values like `group/mysession/g1`.
- It keeps `_uproot_dropout` and `_uproot_settings`.
- It removes all other internal `_uproot_*` fields.
- It leaves `page_order` out of `latest_by_…/`, where it would repeat every page in every row. It stays in the other formats, and `page_times` tells you which page each participant saw when.

Turn filters off only if you need to inspect uproot’s internal bookkeeping.

## CSV or JSONL?

**CSV** is the safe default: It opens directly in Excel, R, Python, Stata, and everything else. Booleans are written as `TRUE`/`FALSE`, missing values as empty cells, and lists or dictionaries as JSON text within the cell.

**JSONL** ([JSON Lines](https://jsonlines.org/): one JSON object per line) is worth choosing when your data contains lists, dictionaries, or mixed types. Unlike CSV, it keeps the distinction between numbers, strings, Booleans, and nested structures. That is, no more guessing whether `"1"` is a number or a string. Both pandas and R read it with one line of code.

Whichever you choose, the briefcase has the same structure—just with `.jsonl` files instead of `.csv` files.

## Analyzing your data

You do not even have to unpack the ZIP: Both R and Python can read individual files straight out of the archive, entirely in memory. The examples below assume you downloaded `mysession_2026-07-04_1412.zip` and that the session is called `mysession`. (Adjust the names accordingly. Of course, if you prefer, you can also unpack the ZIP and read the files from disk; the reading code is the same, minus the ZIP part.)

=== "R"

    With base R, `unz()` opens a single file inside a ZIP as a connection—no extraction needed:

    ```r
    zipfile <- "mysession_2026-07-04_1412.zip"

    players <- read.csv(unz(zipfile, "mysession/latest/player.csv"),
                        check.names = FALSE)
    ```

    (`check.names = FALSE` keeps column names like `!storage` intact instead of mangling them to `X.storage`.)

    With **readr/dplyr**, `read_csv()` accepts the same connection and preserves column names by default:

    ```r
    library(readr)

    players <- read_csv(unz(zipfile, "mysession/latest/player.csv"))
    ```

    With **data.table**, let `fread()` pull the file out of the ZIP via the `unzip` command:

    ```r
    library(data.table)

    players <- fread(cmd = paste("unzip -p", zipfile, "mysession/latest/player.csv"))
    ```

    Column names starting with `!` are perfectly legal in R; just wrap them in backticks:

    ```r
    library(dplyr)

    players |>
        select(`!storage`, payoff) |>
        arrange(desc(payoff))
    ```

    **Merging players with their groups.** With filters applied, each player’s `group` column matches the `!storage` column of `group.csv`:

    ```r
    library(dplyr)

    players <- read_csv(unz(zipfile, "mysession/latest/player.csv"))
    groups <- read_csv(unz(zipfile, "mysession/latest/group.csv"))

    full <- left_join(players, groups,
                      by = c("group" = "!storage"),
                      suffix = c("", ".group"))
    ```

    Or with data.table:

    ```r
    full <- merge(players, groups,
                  by.x = "group", by.y = "!storage",
                  suffixes = c("", ".group"))
    ```

    **JSONL** briefcases work exactly the same way, with jsonlite:

    ```r
    library(jsonlite)

    players <- stream_in(unz(zipfile, "mysession/latest/player.jsonl"))
    ```

=== "Python"

    Python’s built-in `zipfile` module opens a single file inside a ZIP (no extraction needed), and **pandas** reads directly from it:

    ```python
    import zipfile
    import pandas as pd

    with zipfile.ZipFile("mysession_2026-07-04_1412.zip") as zf:
        with zf.open("mysession/latest/player.csv") as f:
            players = pd.read_csv(f)
    ```

    Column names starting with `!` require bracket notation:

    ```python
    players[["!storage", "payoff"]].sort_values("payoff", ascending=False)
    ```

    **Merging players with their groups.** With filters applied, each player’s `group` column matches the `!storage` column of `group.csv`:

    ```python
    with zipfile.ZipFile("mysession_2026-07-04_1412.zip") as zf:
        with zf.open("mysession/latest/player.csv") as f:
            players = pd.read_csv(f)
        with zf.open("mysession/latest/group.csv") as f:
            groups = pd.read_csv(f)

    full = players.merge(groups, how="left",
                         left_on="group", right_on="!storage",
                         suffixes=("", "_group"))
    ```

    **JSONL** briefcases need just one extra argument:

    ```python
    with zipfile.ZipFile("mysession_2026-07-04_1412.zip") as zf:
        with zf.open("mysession/latest/player.jsonl") as f:
            players = pd.read_json(f, lines=True)
    ```

## Verifying your download

Every briefcase includes a `SHA256SUMS` file listing the checksum of every other file in the archive. To confirm that nothing was corrupted or modified—for instance, before archiving data for publication—run this inside the unpacked folder:

```bash
cd mysession
sha256sum -c SHA256SUMS
```

(On macOS, use `shasum -a 256 -c SHA256SUMS`.)

## Page times

Every briefcase includes a `page_times.csv` (or `.jsonl`) file tracking when each player entered and left each page. This is useful for measuring response times. Its columns are:

| Column | Description |
|--------|-------------|
| `sname` | Session name |
| `uname` | Player name |
| `show_page`{ .text-nowrap } | Page index |
| `page_name` | Page class name |
| `entered` | Unix timestamp when the player entered the page |
| `left` | Unix timestamp when the player left the page |
| `context` | Round/context information |

Page times are derived from the players’ `show_page` and `page_order` histories.

## Going further

For everything beyond the download button, see [Advanced data access](https://uproot.science/running/export-advanced.md):

- Downloading briefcases automatically via the [REST API](https://uproot.science/reference/admin-api.md)
- Streaming JSONL exports
- Full database backups with `uproot dump`
- Offline analysis of the database in Python with `uproot.read`

---

Source: https://uproot.science/running/export-advanced.md

# Advanced data access

This page covers programmatic and low-level ways to get at your data: automated exports via the REST API, streaming JSONL, full database backups, and offline analysis with `uproot.read`. If you just want to download and analyze your data, start with [Exporting data](https://uproot.science/running/export.md).

## Export via the REST API

Use the [Admin REST API](https://uproot.science/reference/admin-api.md) for programmatic access. All endpoints used on this page require Bearer token authentication.

### Downloading the briefcase

`GET /admin/api/v1/sessions/{sname}/data/export/` returns the same ZIP briefcase as the admin interface’s **Download data** page:

```bash
curl -OJ -H "Authorization: Bearer YOUR_TOKEN" \
  "https://your-server.com/admin/api/v1/sessions/mysession/data/export/"
```

(`-OJ` saves the file under the name suggested by the server, e.g. `mysession.zip`.)

Query parameters:

| Parameter | Values | Default | Description |
|-----------|--------|---------|-------------|
| `filetype` | `csv`, `jsonl` | `csv` | File type used inside the briefcase |
| `gvar` | field names (repeatable) | — | Adds a grouped latest format (e.g., `latest_by_round/`) |
| `filters` | `true`, `false` | `true` | Apply reasonable filters |

For example, a JSONL briefcase with an extra format grouped by app and round:

```bash
curl -OJ -H "Authorization: Bearer YOUR_TOKEN" \
  "https://your-server.com/admin/api/v1/sessions/mysession/data/export/?filetype=jsonl&gvar=app&gvar=round"
```

!!! note
    The `uproot api` CLI command prints responses as text, so it is not suited to binary downloads like the briefcase. Use `curl` (or any HTTP client) for this endpoint.

### Streaming JSONL

`GET /admin/api/v1/sessions/{sname}/data/jsonl/` streams a single format as JSONL—no ZIP, no splitting by storage kind. This is useful for piping data directly into other tools, or when sessions are large:

```bash
# Raw event log (default)
uproot api sessions/mysession/data/jsonl > mysession.jsonl

# Latest state, with filters
curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://your-server.com/admin/api/v1/sessions/mysession/data/jsonl/?format=latest&filters=true"
```

Query parameters:

| Parameter | Values | Default | Description |
|-----------|--------|---------|-------------|
| `format` | `ultralong`, `sparse`, `latest` | `ultralong` | Export format |
| `gvar` | field names (repeatable) | — | Group-by variables (for `latest`) |
| `filters` | `true`, `false` | `false` | Apply [reasonable filters](https://uproot.science/running/export.md#reasonable-filters) (with `gvar`, this also leaves out `page_order`) |

## Database dumps

For a complete backup of all data, use the dump/restore commands:

```bash
# Dump the entire database to a file
uproot dump --file backup.msgpack.gz

# Restore from a dump
uproot restore --file backup.msgpack.gz
```

You can also download a dump from the admin interface at `/admin/dump/`, or via the REST API:

```bash
curl -o backup.msgpack.gz -H "Authorization: Bearer YOUR_TOKEN" \
  "https://your-server.com/admin/api/v1/database/dump/"
```

!!! note
    Database dumps contain all sessions and all data. Use the briefcase or JSONL endpoints for per-session exports.

## Offline analysis with uproot.read

For analysis in Python (e.g., in a Jupyter notebook), you can open an `uproot.sqlite3` database file directly and navigate it using the same Storage objects the server uses:

```python
from uproot.read import read

db = read("uproot.sqlite3")

for session in db.sessions:
    for group in session.groups:
        for player in group.players:
            with player:
                print(player.name, player.label, player.payoff)

db.close()
```

The `Database` object supports context managers:

```python
with read("uproot.sqlite3") as db:
    session = db.session("my_session")
    with session:
        for player in session.players:
            with player:
                print(player.choice)
```

### Plain-row helpers

For analysis scripts that need regular dictionaries rather than live Storage objects, use the plain-row methods. Each returns a list of dictionaries with identifier columns plus any extra fields you request. Missing fields become `None`.

```python
with read("uproot.sqlite3") as db:
    players = db.player_rows(["label", "role", "payoff"])
    memberships = db.membership_rows()
```

| Method | Identifier columns | Extra fields |
|--------|--------------------|--------------|
| `db.session_rows(fields)`{ .text-nowrap } | `session` | Yes |
| `db.group_rows(fields)` | `session`, `group` | Yes |
| `db.player_rows(fields)` | `session`, `uname` | Yes |
| `db.membership_rows()` | `session`, `group`, `uname`, `position` | No |

To grab all four tables at once, use `snapshot`:

```python
with read("uproot.sqlite3") as db:
    snap = db.snapshot(
        session_fields=["sid"],
        group_fields=["round"],
        player_fields=["label", "role", "payoff"],
    )

print(snap.sessions)      # list of dicts
print(snap.groups)
print(snap.players)
print(snap.memberships)
print(snap.as_dict())      # single dict with all four tables
```

### Database API

| Method/Property | Description |
|-----------------|-------------|
| `db.sessions` | All sessions in the database |
| `db.session(sname)` | Get a session by name |
| `db.group(sname, gname)` | Get a group by session and group name |
| `db.player(sname, uname)` | Get a player by session and username |
| `db.session_rows(fields)`{ .text-nowrap } | Plain dictionaries, one per session |
| `db.group_rows(fields)` | Plain dictionaries, one per group |
| `db.player_rows(fields)` | Plain dictionaries, one per player |
| `db.membership_rows()` | Plain dictionaries, one per group membership |
| `db.snapshot(...)` | All four plain-row tables as a `Snapshot` |
| `db.close()` | Close the database |

!!! note
    `uproot.read` gives you the same Storage objects used at runtime. You can access `player.group`, `player.session`, `group.players`, and all virtual fields. Remember to use `with player:` (or `with session:`, etc.) before reading attributes. The plain-row helpers handle context management internally.

## Summary

| Method | Use case |
|--------|----------|
| [ZIP briefcase](https://uproot.science/running/export.md) | Per-session data for analysis (admin UI or API) |
| Streaming JSONL endpoint | Piping one format into other tools |
| `uproot.read` | Offline analysis in Python/Jupyter |
| `uproot dump` | Full database backup |

---

Source: https://uproot.science/running/deployment.md

# Deployment

This page explains how to make your experiment accessible over the internet. It assumes you have [installed uproot and created a project](https://uproot.science/getting-started/installation.md).

Choose the option that suits you:

1. **[Use Cloudflare](#option-1-use-cloudflare).** If you have a computer with a stable, high-bandwidth internet connection, run uproot there and let Cloudflare connect participants to it. The same approach also works on a VPS.
2. **[Self-host on your own VPS](#option-2-self-host-on-your-own-vps).** A virtual private server (VPS) is a rented computer that you manage remotely. Use nginx and Let’s Encrypt for full control over your web server and HTTPS setup—no third-party service touches participant traffic.
3. **[Use a hosting service](#option-3-use-a-hosting-service).** Deploy to Fly.io, Render, or Heroku using the instructions below.

## Option 1: Use Cloudflare

[Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) connects your computer to Cloudflare, which forwards participants’ requests to uproot. Your experiment and database stay on your computer. Cloudflare provides the public [HTTPS](https://developer.mozilla.org/en-US/docs/Glossary/HTTPS) address, which encrypts the browser connection, without requiring you to configure nginx, certificates, or port forwarding on your router.

### Try it for free

[TryCloudflare](https://try.cloudflare.com/) gives you a temporary public address for free, without a Cloudflare account or your own domain name.

First, [install `cloudflared` for your operating system](https://github.com/cloudflare/cloudflared#installing-cloudflared) and set an [admin password](https://uproot.science/running/admin.md#authentication). In your project directory, [start uproot](https://uproot.science/reference/cli.md#uproot-run):

```bash
uv run uproot run
```

In a second terminal on the same computer, run:

```bash
cloudflared tunnel --url http://localhost:8000
```

The command prints a public address such as `https://random-words.trycloudflare.com`. Leave the tunnel running. In your project’s [`.env` file](https://uproot.science/getting-started/project-structure.md), add or update this line, replacing the example with the address you received:

```text
UPROOT_ORIGIN=https://random-words.trycloudflare.com
```

This tells uproot which public address to use for links. uproot reads `.env` at startup, so stop it with ++ctrl+c++ in its terminal, then run `uv run uproot run` again to pick up the change. Open the public address followed by `/admin/`, sign in, and create a [session or room](https://uproot.science/running/rooms.md). Share the participant links from the admin interface.

Keep both terminals running and prevent the computer from sleeping while anyone is using the experiment. Its internet connection needs sufficient **upload bandwidth**, since your computer sends the experiment’s pages and files to participants. If you restart the Quick Tunnel, update `UPROOT_ORIGIN` with its new address and restart uproot before sharing new links.

!!! note "For testing"
    Cloudflare’s [Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) are intended for testing and development. They have no uptime guarantee and a limit of 200 simultaneous requests. For collecting research data, use a permanent tunnel as described below.

### Use a permanent address for production

For a stable address such as `https://study.example.com`, follow Cloudflare’s [tunnel setup guide](https://developers.cloudflare.com/tunnel/get-started/). You need a Cloudflare account and a domain name added to it. Give the tunnel a name, choose your public hostname, and point it to `http://localhost:8000`. Update `UPROOT_ORIGIN` to the public HTTPS address and restart uproot.

Permanent tunnels and custom hostnames are available on the free plan. For production use, we recommend a [paid Cloudflare plan](https://www.cloudflare.com/plans/pro/) for additional security features. See [current plans and pricing](https://www.cloudflare.com/plans/) before choosing. Payment is not required simply to name a tunnel or use your own domain.

Cloudflare publishes [GDPR safeguards and a data processing addendum](https://www.cloudflare.com/trust-hub/gdpr/). Because participant traffic passes through Cloudflare, your institution should assess whether the setup meets your study’s data-protection requirements.

!!! tip "Use the same setup on a VPS"
    You can also install uproot and `cloudflared` on a VPS and point a permanent tunnel to `http://localhost:8000` there. Cloudflare handles public HTTPS, saving you the nginx and Let’s Encrypt setup, and your experiment and database live on the VPS so your personal computer can be switched off.

    1. Follow the [VPS setup guide](https://uproot.science/running/vps-setup.md) to prepare the server.
    2. [Start uproot on boot](https://uproot.science/running/vps-setup.md#start-uproot-on-boot) with a systemd service.
    3. Use Cloudflare’s tunnel setup in place of the nginx and certificate steps.
    4. [Run `cloudflared` as a service](https://developers.cloudflare.com/tunnel/get-started/#create-a-tunnel) so it starts automatically.

## Option 2: Self-host on your own VPS

For full control over your deployment, run uproot behind [nginx](https://nginx.org/en/docs/beginners_guide.html), a web server that forwards requests to uproot as a *reverse proxy*. You manage the server, certificates, and software yourself. This is the most sovereign option: participants connect directly to your VPS, and you choose who provides the infrastructure. **We strongly recommend that you use a VPS with Debian 13+.** (While uproot itself works flawlessly on Ubuntu, using Ubuntu is in general considered bad practice. uproot also works on OpenBSD.)

!!! tip "New to VPS deployment?"
    If you are setting up a server from scratch, follow the [complete VPS setup guide](https://uproot.science/running/vps-setup.md). It covers everything from getting a VPS to a working HTTPS setup, step by step.

!!! warning "HTTPS required"
    uproot requires HTTPS in production. Many browser features (like the secure cookies needed for accessing the admin area) only work over HTTPS. Use [Let’s Encrypt](https://letsencrypt.org/) with [Certbot](https://certbot.eff.org/instructions) to get free TLS certificates—the credentials your web server uses to provide HTTPS.

### Running uproot

The simplest approach is a tmux session that persists after you disconnect:

```bash
tmux new -s uproot
uproot run  # or: uv run uproot run
```

Detach with ++ctrl+b++ then ++d++. Reattach later with `tmux attach -t uproot`.

For a more robust setup that survives reboots automatically, the [VPS setup guide](https://uproot.science/running/vps-setup.md#start-uproot-on-boot) shows how to create a systemd user service instead.

uproot listens on port 8000 by default. Use `--port` to change it if needed.

### nginx configuration

Configure nginx as a reverse proxy. [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) keep a connection open between the browser and server; the upgrade headers below are required for uproot’s [real-time features](https://uproot.science/multiplayer/real-time.md). The following example config (to be added within an existing `http` block) is battle-tested and has been proven to work reliably:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

server {
    server_name example.com;  # Adjust this

    listen 443 ssl;
    listen 443 quic;
    listen [::]:443 ssl;
    listen [::]:443 quic;
    http2 on;
    add_header Alt-Svc 'h3=":443"; ma=86400';

    ssl_certificate PATH_TO_fullchain.pem;  # Adjust this
    ssl_certificate_key PATH_TO_privkey.pem;  # Adjust this

    location / {
        proxy_pass http://127.0.0.1:8000;  # Maybe adjust this
        proxy_read_timeout 600s;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Front-End-Https on;
        proxy_set_header X-Forwarded-Protocol https;
        proxy_set_header X-Forwarded-Ssl on;
        proxy_set_header X-Url-Scheme https;
        proxy_buffering off;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Optional but recommended: do not log participant WebSockets (avoids log spam)
        location /ws/ {
            proxy_pass http://127.0.0.1:8000;  # Maybe adjust this
            access_log off;
        }
    }
}
```

### Hosting in a subdirectory

If you want to serve uproot at a path like `https://example.com/my-study/` instead of the root, set the `UPROOT_SUBDIRECTORY` environment variable to your chosen path:

```bash
export UPROOT_SUBDIRECTORY=my-study  # use whatever path you want
uproot run
```

Leading and trailing slashes are stripped automatically, so `my-study`, `/my-study`, and `/my-study/` are all equivalent.

When set, uproot prefixes all routes with the subdirectory. Hence, participant pages, the admin interface, static files, and WebSocket connections all work without any further changes to your code or templates.

Then adjust your nginx config to match. Here is a complete example; the only difference from a root deployment is the `location` path:

```nginx
location /my-study/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_read_timeout 600s;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Front-End-Https on;
    proxy_set_header X-Forwarded-Protocol https;
    proxy_set_header X-Forwarded-Ssl on;
    proxy_set_header X-Url-Scheme https;
    proxy_buffering off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    # Optional but recommended: do not log participant WebSockets (avoids log spam)
    location /my-study/ws/ {
        proxy_pass http://127.0.0.1:8000;
        access_log off;
    }
}
```

The admin interface will be at `https://example.com/my-study/admin/`.

!!! tip "Persistent configuration"
    If you run uproot via a systemd service, set the variable in an environment file or in the service unit’s `Environment=` directive so it persists across restarts.

## Option 3: Use a hosting service

These services run uproot on infrastructure they manage. Choose [Fly.io](#flyio-with-sqlite) or [Render](#render-with-sqlite) to keep the default SQLite database, or [Heroku](#heroku) with PostgreSQL. Follow the storage instructions for your chosen service so that saved experiment data survives restarts.

### Fly.io with SQLite

[Fly.io](https://fly.io/) supports long-running WebSocket connections and persistent volumes, so you can run uproot with its default SQLite database. Fly charges for the Machines, volumes, and network traffic that you use; check its [current pricing](https://fly.io/docs/about/pricing/) before you deploy.

!!! tip "Why Fly.io?"
    - Full support for WebSocket connections and real-time features
    - Works with uproot’s default SQLite database on a persistent volume
    - Regional deployment for lower latency
    - Native support for persistent volumes

#### Prerequisites

1. Create a [Fly.io account](https://fly.io/app/sign-up)
2. Install the [Fly CLI](https://fly.io/docs/flyctl/install/):

=== "macOS / Linux"
    ```bash
    curl -L https://fly.io/install.sh | sh
    ```

=== "Windows (PowerShell)"
    ```powershell
    powershell -ExecutionPolicy ByPass -c "iwr https://fly.io/install.ps1 -useb | iex"
    ```

3. Log in to Fly:

```bash
fly auth login
```

#### Deploy your experiment

Navigate to your uproot project directory and run:

```bash
fly launch --no-deploy
```

The `--no-deploy` option is important: it lets you attach persistent storage before uproot starts for the first time. Fly will detect your Python application and guide you through the setup. When prompted:

- Choose an app name or let Fly generate one
- Choose a region close to your participants
- Do not add PostgreSQL or Redis if Fly offers them

This creates a `fly.toml` configuration file. Edit it to ensure the correct settings:

```toml
app = "your-app-name"
primary_region = "iad"  # or your chosen region

[build]
  builder = "paketobuildpacks/builder-jammy-base"

[env]
  PORT = "8080"
  UPROOT_SQLITE3 = "/data/uproot.sqlite3"
  UPROOT_ORIGIN = "https://your-app-name.fly.dev"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = "off"  # Keep WebSocket connections alive
  auto_start_machines = true
  min_machines_running = 1

[[vm]]
  memory = "512mb"
  cpu_kind = "shared"
  cpus = 1
```

Replace `your-app-name` and `iad` with the app name and region selected by `fly launch`.
If you later add a custom domain, update `UPROOT_ORIGIN` to that domain.

#### Adding persistent storage for SQLite

Create a persistent volume to ensure your SQLite database survives app restarts:

```bash
fly volumes create uproot_data --region iad --size 1
```

Update your `fly.toml` to mount the volume:

```toml
[mounts]
  source = "uproot_data"
  destination = "/data"
```

The `UPROOT_SQLITE3` setting above points uproot at the mounted volume.

!!! warning "Use one Machine"
    A Fly volume is attached to one Machine and is not automatically replicated. Keep this SQLite deployment at one Machine; do not clone the Machine or scale it horizontally.

#### Set the admin password

New uproot projects use `upd.auto_login()` in `main.py`. Set the production password as a Fly secret so that it is not committed to Git:

```bash
fly secrets set UPROOT_ADMIN_PASSWORD='choose-a-long-random-password'
```

If your project does not already contain this line, add it to `main.py`:

```python
upd.ADMINS["admin"] = upd.auto_login()
```

#### Deploy

Deploy your application:

```bash
fly deploy
```

After deployment completes, Fly will show your app’s URL. Open it in your browser:

```bash
fly open
```

#### Monitoring and logs

View your app’s logs:

```bash
fly logs
```

Check app status:

```bash
fly status
```

Access the admin interface at `https://your-app-name.fly.dev/admin/`.

### Render with SQLite

[Render](https://render.com/) supports WebSockets without a fixed connection timeout and can attach a persistent disk to a paid web service. Do not use a free web service for a real experiment: free services cannot attach a disk, and their local SQLite files are [lost when the service restarts, redeploys, or spins down](https://render.com/docs/free#local-files-lost-on-redeploy).

#### Quick start

1. Sign up at [render.com](https://render.com/) and select **New > Web Service**.
2. Connect the Git repository that contains your uproot project and choose the **Python 3** runtime.
3. Set the build command to `pip install -r requirements.txt`.
4. Set the start command to `uproot run -h 0.0.0.0 -p $PORT`.
5. Choose a paid instance type. Under **Advanced**, add a persistent disk with the mount path `/var/data`.
6. Add the following environment variables, replacing the example service name and password:

    ```text
    UPROOT_SQLITE3=/var/data/uproot.sqlite3
    UPROOT_ORIGIN=https://your-service-name.onrender.com
    UPROOT_ADMIN_PASSWORD=choose-a-long-random-password
    ```

7. Create the web service. When the first deploy finishes, open `https://your-service-name.onrender.com/admin/`.

The password variable assumes that `main.py` contains `upd.ADMINS["admin"] = upd.auto_login()`, as current uproot projects do. Add that line if you have an older project. Render’s Python runtime uses the project’s `.python-version` file, so the file generated by uproot selects a compatible Python version. If you add a custom domain, update `UPROOT_ORIGIN` to its full `https://` URL.

!!! warning "Persistent-disk limitations"
    A Render persistent disk is available to only one service instance and [disables zero-downtime deploys](https://render.com/docs/disks#disk-limitations-and-considerations). This matches uproot’s single-process live state, but it means each deploy briefly stops the experiment. Keep one instance and do not deploy while participants are active. Render can also replace an instance during platform maintenance, which closes its [WebSocket connections](https://render.com/docs/websocket#faq).

### Heroku

Heroku can run uproot with PostgreSQL. A Heroku dyno’s local disk is temporary, so the default SQLite database is not suitable for production there.

!!! note "WebSocket timeout"
    Heroku supports WebSockets and applies a rolling [55-second idle timeout](https://devcenter.heroku.com/articles/websockets). uproot sends a heartbeat every 9 seconds, so a participant reading or waiting does not leave the connection idle long enough to hit that limit.

!!! warning "Dyno restarts"
    Heroku [restarts dynos at least daily](https://devcenter.heroku.com/articles/dyno-restarts), as well as after deploys and configuration changes. PostgreSQL keeps the saved data, but a restart interrupts active connections and in-process background tasks. Restart or deploy shortly before a scheduled experiment, and do not change the app while participants are active.

#### Prerequisites

1. Create a [Heroku account](https://signup.heroku.com/)
2. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)
3. Log in to Heroku:

```bash
heroku login
```

#### Deploy your experiment

First, add PostgreSQL support to the project’s main dependencies:

```bash
uv add 'uproot-science[pg]<1'
```

This updates `pyproject.toml` and `uv.lock`. Current uproot projects also contain a `requirements.txt`, but Heroku’s Python buildpack [requires exactly one package-manager file](https://github.com/heroku/heroku-buildpack-python/blob/main/lib/package_manager.sh). Remove `requirements.txt` from Git so that Heroku uses the lock file:

```bash
git rm requirements.txt
git add pyproject.toml uv.lock
git commit -m "Configure Heroku deployment"
```

The generated `Procfile` already binds uproot to Heroku’s `$PORT`. Make sure `main.py` contains `upd.ADMINS["admin"] = upd.auto_login()`. Then create the app, enable runtime metadata so uproot can discover its public URL, add PostgreSQL, and set the admin password:

```bash
# Create a new Heroku app
heroku create my-experiment-name

# Enable runtime metadata
heroku labs:enable runtime-dyno-metadata

# Provision PostgreSQL
heroku addons:create heroku-postgresql:essential-0
heroku pg:wait

# Store the admin password outside the source code
heroku config:set UPROOT_ADMIN_PASSWORD='choose-a-long-random-password'

# Deploy
git push heroku main

# Keep uproot on exactly one web dyno
heroku ps:scale web=1:basic

# Open the admin interface
heroku open /admin/
```

If your local branch is not named `main`, deploy it with `git push heroku HEAD:main` instead. The PostgreSQL add-on sets `DATABASE_URL`; uproot detects it automatically. If you add a custom domain, set `UPROOT_ORIGIN` to its full `https://` URL with `heroku config:set`.

!!! warning "Do not scale horizontally"
    uproot’s live connection queues and background tasks belong to one server process. Keep exactly one web dyno; adding more web dynos would split participants across processes that do not share that live state.

---

Source: https://uproot.science/running/vps-setup.md

# VPS setup guide

Step-by-step instructions for deploying uproot on a fresh virtual private server (VPS). This guide assumes no prior server administration experience and produces a minimal, production-ready setup.

## 1. Get a VPS

Rent a virtual private server from any major provider. [Hetzner](https://www.hetzner.com/), [DigitalOcean](https://www.digitalocean.com/), and [AWS Lightsail](https://aws.amazon.com/lightsail/) are all good options. uproot is lightweight; the smallest available tier (1 vCPU, 512 MB to 1 GB RAM) is sufficient for most experiments.

Choose **Debian** (latest stable) as the operating system. We recommend Debian for reasons we need not elaborate here.

## 2. Secure SSH access

### Generate an SSH key (on your local machine)

If you do not yet have an SSH key, create one:

```console
you@local:~$ ssh-keygen -t ed25519
```

Accept the default file location. Your public key is at `~/.ssh/id_ed25519.pub`.

Most VPS providers let you paste your public key during server creation. If you did that, you can SSH in as root immediately:

```console
you@local:~$ ssh root@YOUR_SERVER_IP
```

If you did not add a key during creation, log in with the password the provider gave you, then install your key:

```console
you@local:~$ ssh-copy-id -i ~/.ssh/id_ed25519.pub root@YOUR_SERVER_IP
```

### Create a non-root user

Do not run experiments as root. Create a normal user and give it sudo access:

```console
root@server:~# adduser uproot
root@server:~# usermod -aG sudo uproot
```

`adduser` asks you to set a password (you will need it for `sudo` later) and some optional fields you can skip by pressing Enter.

### Set up SSH keys for both users

Your root account already has your SSH key. Copy it to the new user so you can log in as either:

```console
root@server:~# mkdir -p /home/uproot/.ssh
root@server:~# cp /root/.ssh/authorized_keys /home/uproot/.ssh/authorized_keys
root@server:~# chown -R uproot:uproot /home/uproot/.ssh
root@server:~# chmod 700 /home/uproot/.ssh
root@server:~# chmod 600 /home/uproot/.ssh/authorized_keys
```

### Disable password login

Root login is still allowed, but only with SSH keys. This is convenient for emergency administration while still preventing password-based logins.

```console
root@server:~# nano /etc/ssh/sshd_config.d/99-keys-only.conf
```

!!! tip
    In `nano`, save with ++ctrl+s++ and exit with ++ctrl+x++.

Paste the following:

```ini
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
```

Then test and apply:

```console
root@server:~# sshd -t
root@server:~# systemctl restart ssh
```

!!! warning
    Before closing this session, verify in a **separate terminal** that your key works for **both** users:

    ```console
    you@local:~$ ssh root@YOUR_SERVER_IP
    you@local:~$ ssh uproot@YOUR_SERVER_IP
    ```

    Both must succeed. If either fails, fix it before closing your root session. Otherwise you may lock yourself out.

From now on, use `root@server` for system administration and `uproot@server` for managing your experiment.

## 3. Install system packages

Still as root:

```console
root@server:~# apt update && apt install -y git python3 python-is-python3 python3-pip python3-venv nginx certbot python3-certbot-nginx tmux unattended-upgrades dnsutils
root@server:~# dpkg-reconfigure -plow unattended-upgrades
```

Select “Yes” when prompted to enable automatic security updates. This ensures critical patches are installed without your intervention.

## 4. Set up your project

From here on, work as the `uproot` user:

```console
you@local:~$ ssh uproot@YOUR_SERVER_IP
```

### Upload your project

In a separate terminal on your local machine, copy your uproot project to the server:

```console
you@local:~$ rsync --delete -Pa my_project uproot@YOUR_SERVER_IP:
```

### Before going to production

Edit `main.py` on the server to set a real admin password:

```python
upd.ADMINS["admin"] = "your-secure-password"
```

### Create a Python environment

```console
uproot@server:~$ python3 -m venv ~/env
uproot@server:~$ source ~/env/bin/activate
(env) uproot@server:~$ cd ~/my_project
(env) uproot@server:~/my_project$ pip install -Ue .
```

Verify it works:

```console
(env) uproot@server:~/my_project$ uproot run -h 127.0.0.1 -p 8000
```

Stop it with ++ctrl+c++ once you see the startup output. The remaining steps do not require the virtual environment.

### Create a run script

```console
uproot@server:~$ nano ~/run.sh
```

Paste the following:

```bash
#!/bin/bash
set -e
cd ~/my_project
source ~/env/bin/activate
pip install -Ue .
export UPROOT_ORIGIN=https://example.com
exec uproot run -h 127.0.0.1 -p 8000
```

Save, exit, then make it executable:

```console
uproot@server:~$ chmod +x ~/run.sh
```

Replace `example.com` with your actual hostname (see [step 5](#choose-a-hostname)).

The script reinstalls your project on every start (picking up any dependency changes) and starts uproot on a local-only port. nginx will handle the public-facing side.

### Start uproot on boot

There are two ways to keep uproot running: a systemd user service (automatic, survives reboots) or tmux (manual, simpler to understand). We recommend systemd, but tmux is a perfectly reasonable alternative. See the [deployment page](https://uproot.science/running/deployment.md#running-uproot) for the tmux approach.

#### systemd (recommended)

Create a user-level systemd service so uproot starts automatically when the server boots. First enable lingering, which allows the user’s systemd services to run even when the user is not logged in:

```console
uproot@server:~$ sudo loginctl enable-linger uproot
```

Then create the service:

```console
uproot@server:~$ mkdir -p ~/.config/systemd/user
uproot@server:~$ nano ~/.config/systemd/user/uproot.service
```

Paste the following:

```ini
[Unit]
Description=uproot experiment server
After=network.target

[Service]
Type=exec
ExecStart=%h/run.sh
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
```

Enable and start it:

```console
uproot@server:~$ systemctl --user daemon-reload
uproot@server:~$ systemctl --user enable uproot
uproot@server:~$ systemctl --user start uproot
```

Useful commands:

| Command | What it does |
|---------|-------------|
| `systemctl --user status uproot` | Check if uproot is running |
| `systemctl --user restart uproot` | Restart after updating your project |
| `journalctl --user -u uproot -f` | Follow the live log output |
| `systemctl --user stop uproot` | Stop the server |

## 5. nginx and HTTPS

### Choose a hostname

You need a domain name for HTTPS. A custom domain is not required; there are easier options:

- **Provider-assigned hostname:** Some VPS providers let you assign a hostname through their control panel. If your provider offers this and the hostname already resolves to your server’s IP, you can use it directly and skip the DNS step below.
- **University subdomain:** Many universities allow researchers to create subdomains (such as `experiment.econ.uni-example.de`). Ask your IT department. This is often the easiest and most professional-looking option.
- **Your own domain:** Register one from any registrar if you prefer full control.

If you are using a university subdomain or your own domain, create a **DNS A record** pointing it to your server’s IP address. Wait for it to propagate (usually a few minutes, sometimes up to an hour). You can verify with:

```console
uproot@server:~$ dig +short example.com
```

This should return your server’s IP.

### Get a TLS certificate

Stop nginx temporarily so certbot can use port 80:

```console
uproot@server:~$ sudo systemctl stop nginx
uproot@server:~$ sudo certbot certonly --standalone -d example.com
```

Follow the prompts. Certbot stores the certificate at `/etc/letsencrypt/live/example.com/`.

### Set up automatic renewal

Certbot installs a systemd timer that attempts renewal twice daily. Since we used standalone mode, nginx needs to briefly stop during renewal. Create hooks for this:

```console
uproot@server:~$ sudo mkdir -p /etc/letsencrypt/renewal-hooks/pre /etc/letsencrypt/renewal-hooks/post
uproot@server:~$ sudo nano /etc/letsencrypt/renewal-hooks/pre/stop-nginx
```

Paste the following:

```bash
#!/bin/bash
systemctl stop nginx
```

Then create the post-renewal hook:

```console
uproot@server:~$ sudo nano /etc/letsencrypt/renewal-hooks/post/start-nginx
```

Paste the following:

```bash
#!/bin/bash
systemctl start nginx
```

Make both hooks executable:

```console
uproot@server:~$ sudo chmod +x /etc/letsencrypt/renewal-hooks/pre/stop-nginx /etc/letsencrypt/renewal-hooks/post/start-nginx
```

Certificates are valid for 90 days. Renewal is attempted automatically roughly every three weeks once a certificate is past the halfway point. Verify the setup with:

```console
uproot@server:~$ sudo certbot renew --dry-run
```

### Configure nginx

Remove the default site and create the uproot configuration:

```console
uproot@server:~$ sudo rm -f /etc/nginx/sites-enabled/default
uproot@server:~$ sudo nano /etc/nginx/sites-available/uproot
```

Paste the following. Replace every occurrence of `example.com` with your hostname:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    server_name example.com;

    listen 443 ssl;
    listen 443 quic;
    listen [::]:443 ssl;
    listen [::]:443 quic;
    http2 on;
    add_header Alt-Svc 'h3=":443"; ma=86400';

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_read_timeout 600s;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Front-End-Https on;
        proxy_set_header X-Forwarded-Protocol https;
        proxy_set_header X-Forwarded-Ssl on;
        proxy_set_header X-Url-Scheme https;
        proxy_buffering off;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Optional but recommended: do not log participant WebSockets (avoids log spam)
        location /ws/ {
            proxy_pass http://127.0.0.1:8000;
            access_log off;
        }
    }
}
```

Enable the site and start nginx:

```console
uproot@server:~$ sudo ln -sf /etc/nginx/sites-available/uproot /etc/nginx/sites-enabled/uproot
uproot@server:~$ sudo nginx -t && sudo systemctl start nginx
```

`nginx -t` tests the configuration. If it reports errors, double-check that you replaced all occurrences of `example.com` and that the certificate paths exist.

## 6. Go live

If uproot is not already running via the systemd service:

```console
uproot@server:~$ systemctl --user start uproot
```

Your experiment is now live at `https://example.com/admin/`.

### Updating your experiment

Upload the new version from your local machine, then restart:

```console
you@local:~$ rsync --delete -Pa my_project uproot@YOUR_SERVER_IP:
```

```console
uproot@server:~$ systemctl --user restart uproot
```

The run script automatically reinstalls your project and picks up any changes.

### Maintenance

Restart your server regularly (e.g., every few weeks, and always between experiment sessions) to apply kernel updates installed by `unattended-upgrades`. Many security patches only take full effect after a reboot:

```console
uproot@server:~$ sudo reboot
```

uproot starts back up automatically thanks to the systemd service. After reconnecting, verify:

```console
uproot@server:~$ systemctl --user status uproot
```

---

Source: https://uproot.science/reference/fields.md

# Form fields

All fields accept keyword-only arguments. See [Collecting data with forms](https://uproot.science/building/forms.md) for usage patterns.

## Common parameters

These parameters are available on most fields:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `label` | `str` | `""` | Question text shown to the participant |
| `optional` | `bool` | `False` | Allow empty responses |
| `description` | `str` | `""` | Help text below the field |
| `default` | any | `None` | Pre-filled value |
| `render_kw` | `dict` | `None` | HTML attributes for the input element |
| `widget` | any | `None` | Custom WTForms widget |
| `class_wrapper`{ .text-nowrap } | `str` | `None` | CSS class for the wrapper div |

For several Likert items sharing one scale, use the `likert_matrix()` or `likert_grid()` template macros. See [Collecting data with forms](https://uproot.science/building/forms.md#scales-and-sliders).

## `BooleanField`

A single checkbox.

```python
consent=BooleanField(label="I agree to participate")
```

| Parameter | Type | Default |
|-----------|------|---------|
| `label` | `str` | `""` |
| `render_kw` | `dict` | `None` |
| `description` | `str` | `""` |
| `widget` | any | `None` |
| `default` | any | `None` |
| `class_wrapper`{ .text-nowrap } | `str` | `None` |
| `validators` | list | — |

## `BoundedChoiceField`

Multi-select checkboxes with min/max selection limits.

```python
topics=BoundedChoiceField(
    label="Select your preferred topics",
    choices=["Economics", "Psychology", "Sociology"],
    min=1,
    max=3,
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `choices` | list or dict | — | Available options |
| `min` | `int` | `0` | Minimum number of selections |
| `max` | `int` | `None` | Maximum selections (`None` = unlimited) |
| `layout` | `str` | `"vertical"` | `"vertical"` or `"horizontal"` |
| `label` | `str` | `""` | |
| `description` | `str` | `""` | |
| `render_kw` | `dict` | `None` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper`{ .text-nowrap } | `str` | `None` | |

Returns a list of selected values.

[See the bounded_choice example](https://github.com/mrpg/uproot-examples/tree/main/bounded_choice)

## `DateField`

A date picker.

```python
birthdate=DateField(label="Date of birth")
```

| Parameter | Type | Default |
|-----------|------|---------|
| `label` | `str` | `""` |
| `optional` | `bool` | `False` |
| `render_kw` | `dict` | `None` |
| `description` | `str` | `""` |
| `widget` | any | `None` |
| `default` | any | `None` |
| `class_wrapper`{ .text-nowrap } | `str` | `None` |

## `DecimalField`

Decimal number input with optional addons and min/max bounds.

```python
amount=DecimalField(
    label="Amount to contribute",
    min=0.0,
    max=100.0,
    step=0.01,
    addon_start="$",
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `min` | number | — | Minimum value |
| `max` | number | — | Maximum value |
| `step` | number | — | Step increment |
| `addon_start` | `str` | — | Text before the input (e.g., `"$"`) |
| `addon_end` | `str` | — | Text after the input (e.g., `"EUR"`) |
| `class_addon_start`{ .text-nowrap } | `str` | `""` | CSS class for start addon |
| `class_addon_end` | `str` | `""` | CSS class for end addon |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

## `FloatField`

Like `DecimalField`, but stores a Python `float`. Prefer `DecimalField` for money.

```python
reaction_time=FloatField(label="Reaction time (seconds)", min=0.0, step=0.001)
```

It accepts the same `min`, `max`, `step`, addon, label, optional, rendering, and wrapper parameters as `DecimalField`.

## `DecimalRangeField`

A slider input.

```python
confidence=DecimalRangeField(
    label="How confident are you?",
    min=0,
    max=100,
    step=1,
    label_min="Not at all",
    label_max="Completely",
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `min` | number | — | Minimum value |
| `max` | number | — | Maximum value |
| `step` | number | `1.0` | Step increment |
| `label_min` | `str` | `None` | Label below left end (defaults to `str(min)`) |
| `label_max` | `str` | `None` | Label below right end (defaults to `str(max)`) |
| `breakpoint` | `int` | `992` | Viewport width in pixels at or above which endpoint labels no longer wrap |
| `label_max_min_class_suffix`{ .text-nowrap } | `str` | `"-custom"` | CSS class suffix for endpoint labels |
| `hide_popover` | `bool` | `False` | Hide the current value popover |
| `anchoring` | `bool` | `True` | Show initial slider position |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

[See the input_elements example](https://github.com/mrpg/uproot-examples/tree/main/input_elements)

## `FloatRangeField`

Like `DecimalRangeField`, but stores a Python `float`. It accepts the same slider parameters (`min`, `max`, `step`, `label_min`, `label_max`, `breakpoint`, `hide_popover`, `anchoring`, and the rest).

## `EmailField`

HTML email input. It checks required/optional status like other fields; add a page `validate` method if you need stricter server-side email validation.

```python
email=EmailField(label="Your email address")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `label_floating`{ .text-nowrap } | `str` | `None` | Floating label inside the input |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

## `FileField`

File upload. Always treated as a stealth field. Process in `handle_stealth_fields`.

```python
document=FileField(label="Upload your document")
```

See [File uploads](https://uproot.science/advanced/uploads.md) for details.

| Parameter | Type | Default |
|-----------|------|---------|
| `label` | `str` | `""` |
| `optional` | `bool` | `False` |
| `render_kw` | `dict` | `None` |
| `description` | `str` | `""` |
| `widget` | any | `None` |
| `default` | any | `None` |
| `class_wrapper`{ .text-nowrap } | `str` | `None` |

## `IBANField`

IBAN input with format validation. Install the optional `iban` extra first with `uv add 'uproot-science[iban]<1'`; it provides the `schwifty` library used for validation.

```python
iban=IBANField(label="Your IBAN")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `addon_start` | `str` | — | Text before the input |
| `addon_end` | `str` | — | Text after the input |
| `class_addon_start`{ .text-nowrap } | `str` | `""` | CSS class for start addon |
| `class_addon_end` | `str` | `""` | CSS class for end addon |
| `label_floating` | `str` | `None` | Floating label inside the input |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

[See the payment_data example](https://github.com/mrpg/uproot-examples/tree/main/payment_data)

## `IntegerField`

Whole number input with optional addons and min/max bounds.

```python
age=IntegerField(label="How old are you?", min=18, max=100)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `min` | `int` | — | Minimum value |
| `max` | `int` | — | Maximum value |
| `addon_start` | `str` | — | Text before the input |
| `addon_end` | `str` | — | Text after the input |
| `class_addon_start`{ .text-nowrap } | `str` | `""` | CSS class for start addon |
| `class_addon_end` | `str` | `""` | CSS class for end addon |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

## `BICField`

BIC/SWIFT code input with format validation (requires the optional `iban` extra described under [IBANField](#ibanfield)).

```python
bic=BICField(label="Your bank’s BIC")
```

It accepts the same addon, label, optional, rendering, and wrapper parameters as
`IBANField`. See [the payment data example](https://github.com/mrpg/uproot-examples/tree/main/payment_data).

## `LikertField`

Rating scale rendered as radio buttons with labeled endpoints.

```python
satisfaction=LikertField(
    label="How satisfied are you?",
    label_min="Very unsatisfied",
    label_max="Very satisfied",
    min=1,
    max=7,
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `min` | `int` | `1` | Lowest value |
| `max` | `int` | `7` | Highest value |
| `label_min` | `str` | `""` | Label below the lowest value |
| `label_max` | `str` | `""` | Label below the highest value |
| `breakpoint` | `int` | `992` | Viewport width in pixels at or above which the scale stays horizontal |
| `choices` | list or dict | auto | Override the auto-generated integer choices |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper`{ .text-nowrap } | `str` | `None` | |

Auto-generates integer choices from `min` to `max`.

## `LikertFieldClassic`

The same scale as `LikertField`, always rendered as a table. Use this when you do not want the responsive layout. It accepts `min`, `max`, `label_min`, `label_max`, `choices`, and the usual label/optional/rendering parameters, but not `breakpoint`.

## `RadioField`

Choose one option with radio buttons.

```python
choice=RadioField(
    label="Do you agree?",
    choices=[(True, "Yes"), (False, "No")],
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `choices` | list, dict, or `None` | `None` | Options (defaults to `[True, False]` if `None`) |
| `layout` | `str` | `"vertical"` | `"vertical"` or `"horizontal"` |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper`{ .text-nowrap } | `str` | `None` | |

Choices can be:

- `list[tuple[value, label]]`, for example, `[(1, "Option A"), (2, "Option B")]`;
- `dict[value, label]`, for example, `{"a": "Option A", "b": "Option B"}`;
- `list[value]`, for example, `[1, 2, 3]` (value and label are the same).

## `SelectField`

Dropdown selection.

```python
country=SelectField(
    label="Country",
    choices=[("us", "United States"), ("de", "Germany")],
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `choices` | list or dict | — | Options (required) |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper`{ .text-nowrap } | `str` | `None` | |

Accepts the same choice formats as `RadioField`.

The dropdown starts with an empty option (“—”), which is selected until the participant picks a choice. For required fields, submitting the empty option fails validation. For optional fields, it saves `None`. If your choices already include an empty value (`""`), no extra option is added, so you can label it yourself:

```python
country=SelectField(
    label="Country",
    choices=[("", "Please choose"), ("us", "United States"), ("de", "Germany")],
)
```

With your own empty choice, an optional field saves `""` instead of `None`.

## `StringField`

Single-line text input.

```python
participant_name=StringField(label="Your name")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `addon_start` | `str` | — | Text before the input |
| `addon_end` | `str` | — | Text after the input |
| `class_addon_start`{ .text-nowrap } | `str` | `""` | CSS class for start addon |
| `class_addon_end` | `str` | `""` | CSS class for end addon |
| `label_floating` | `str` | `None` | Floating label inside the input |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

## `TextAreaField`

Multi-line text input.

```python
comments=TextAreaField(label="Additional comments", optional=True)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `addon_start` | `str` | — | Text before the input |
| `addon_end` | `str` | — | Text after the input |
| `class_addon_start`{ .text-nowrap } | `str` | `""` | CSS class for start addon |
| `class_addon_end` | `str` | `""` | CSS class for end addon |
| `label_floating` | `str` | `None` | Floating label inside the input |
| `label` | `str` | `""` | |
| `optional` | `bool` | `False` | |
| `render_kw` | `dict` | `None` | |
| `description` | `str` | `""` | |
| `widget` | any | `None` | |
| `default` | any | `None` | |
| `class_wrapper` | `str` | `None` | |

[See all field types in the input_elements example](https://github.com/mrpg/uproot-examples/tree/main/input_elements)

---

Source: https://uproot.science/reference/page-methods.md

# Page methods

Page methods are `@classmethod`s that receive `page` (the page class) and usually `player` (the current participant) as arguments. Standard page hooks can be sync or async; wait-page callbacks such as `after_grouping` and `all_here` are sync-only.

## Lifecycle overview

For a forward visit to a page, the lifecycle is:

1. **`show`:** Decide whether the page is displayed.
2. **`early`:** Run before rendering.
3. **`before_always_once`:** Run once when this page position is reached.
4. **`before_once`:** Run once per player, on first visit only.
5. **`fields`:** Determine form fields.
6. **`templatevars` and `jsvars`:** Prepare template and JS data.
7. *(The page is rendered and displayed.)*
8. *(The participant submits.)*
9. **`validate`:** Check submitted data.
10. **`before_form_save`:** Run before ordinary form fields are saved.
11. **`stealth_fields` and `handle_stealth_fields`:** Handle manual fields.
12. **`may_proceed`:** Gate before advancing.
13. **`after_once`:** Run once per player, on first successful submission only.
14. **`after_always_once`:** Run once after this page position is submitted.

The `timeout` method configures the deadline while the page is rendered. The browser submits when the deadline is reached. If the server receives a request after the deadline, `timeout_reached` handles that request before normal form processing, `may_proceed` is skipped, and the page advances. Valid form data is saved; invalid data is discarded without an error message. uproot counts a request up to `upd.TIMEOUT_TOLERANCE` seconds (default: 1) before the deadline as timed out, too, but only if `may_proceed` allows the page to advance. See [Timeouts and `may_proceed`](https://uproot.science/advanced/timeouts.md#timeouts-and-may_proceed). A direct visit to the current page can omit `early`, because that hook runs when entering a new page position.

## `show`

Decides whether the page should be displayed. Return `False` to skip the page entirely.

```python
@classmethod
def show(page, player):
    return player.role == "proposer"
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `bool`: `True` to show (default), `False` to skip |

## `early`

The earliest hook in the page lifecycle. Runs before `before_always_once`. Receives the HTTP request as an additional keyword argument.

```python
@classmethod
def early(page, player, request):
    player.user_agent = request.headers.get("user-agent", "")
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `request` | The Starlette `Request` object |

## `before_once`

Runs **once per player**, the first time they see this page. Does not run again if the player navigates back and returns.

```python
@classmethod
def before_once(page, player):
    player.start_time = time()
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `before_always_once`

Runs once when this page position is reached. Use this for setup that should also happen for internal or skipped pages.

```python
@classmethod
def before_always_once(page, player):
    player.visit_count = getattr(player, "visit_count", 0) + 1
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `fields`

Returns the form fields for this page. Can be a dictionary (static) or a method (dynamic).

### Static fields

```python
class Survey(Page):
    fields = dict(
        age=IntegerField(label="Age", min=18, max=100),
    )
```

### Dynamic fields

```python
@classmethod
def fields(page, player):
    max_offer = player.other_in_group.endowment
    return dict(
        offer=IntegerField(label="Your offer", min=0, max=max_offer),
    )
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` mapping field names to field instances |

## `templatevars`

Returns variables available in the page template.

```python
@classmethod
def templatevars(page, player):
    return dict(
        partner=player.other_in_group,
        total=sum(p.contribution for p in player.group.players),
    )
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` of template variables, or `None` |

`templatevars` may return `None` instead of a dict. This is treated the same as returning `{}`.

## `jsvars`

Returns variables available in JavaScript as `uproot.vars`.

```python
@classmethod
def jsvars(page, player):
    return dict(
        initial_price=player.price,
        max_trades=C.MAX_TRADES,
    )
```

Access in JavaScript:

```javascript
const price = uproot.vars.initial_price;
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` of JavaScript variables |

## `validate`

Custom validation of submitted form data. Called after built-in field validation passes.

```python
@classmethod
def validate(page, player, data):
    if data["give_a"] + data["give_b"] > 100:
        return "Total cannot exceed 100"
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | `dict` of submitted values |
| **Returns** | Error message(s), or nothing if valid |

Return types are:

- `str`: single error displayed at the top of the form;
- `list[str]`: multiple errors displayed at the top;
- `dict[str, str | list[str]]`: per-field errors displayed next to each field.

```python
@classmethod
def validate(page, player, data):
    errors = {}
    if data["min_price"] > data["max_price"]:
        errors["min_price"] = "Must be less than max price"
        errors["max_price"] = "Must be greater than min price"
    return errors or None
```

[See the input_validation example](https://github.com/mrpg/uproot-examples/tree/main/input_validation)

## `before_form_save`

Runs after built-in and custom validation passes, immediately before ordinary
form fields are saved to the player. The submitted `data` mapping is read-only;
use this hook for checks or side effects that need the validated values before
they are persisted.

```python
@classmethod
def before_form_save(page, player, data):
    player.submitted_at = time()
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | Read-only mapping of validated form values |

## `may_proceed`

Gate that controls whether the player can advance. Return `False` to keep the player on the page.

```python
@classmethod
def may_proceed(page, player):
    from time import time
    return time() >= player.session.detection_period_until
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `bool`: `True` to allow proceeding (default), `False` to block |

!!! note
    A page [timeout](#timeout) overrides `may_proceed` once the deadline has passed. See [Timeouts and `may_proceed`](https://uproot.science/advanced/timeouts.md#timeouts-and-may_proceed).

## `after_once`

Runs **once per player**, after the first forward submission. Does not run on back navigation or revisits.

```python
@classmethod
def after_once(page, player):
    player.completed_task = True
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `after_always_once`

Runs once after this page position is submitted in the forward direction.

```python
@classmethod
def after_always_once(page, player):
    player.attempts += 1
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

!!! note
    `after_once` and `after_always_once` are not available on `GroupCreatingWait` or `SynchronizingWait` pages.

## `timeout`

Sets a page timeout in seconds. Can be a static value or a method.

### Static timeout

```python
class TimedTask(Page):
    timeout = 60
```

### Dynamic timeout

```python
@classmethod
def timeout(page, player):
    return max(0, player.deadline - time())
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `float` seconds until timeout, or `None` to disable |

## `timeout_reached`

Called when the page timeout expires, before the page auto-advances.

```python
@classmethod
def timeout_reached(page, player):
    player.timed_out = True
    player.score = 0
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `stealth_fields`

Specifies which fields should not be saved automatically. Can be a list or a method.

### Static stealth fields

```python
class MyPage(Page):
    stealth_fields = ["code", "iban"]
```

### Dynamic stealth fields

```python
@classmethod
def stealth_fields(page, player):
    return ["response"] if player.condition == "special" else []
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `list[str]` of field names to handle manually |

## `handle_stealth_fields`

Processes stealth fields. Called after validation. Return error strings to reject the submission.

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    code = data["code"]
    if code != "secret123":
        return "Invalid access code"
    player.verified = True
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | `dict` of stealth field values |
| **Returns** | Error string or list of error strings, or nothing if valid |

[See the payment_data example](https://github.com/mrpg/uproot-examples/tree/main/payment_data) · [input_validation example](https://github.com/mrpg/uproot-examples/tree/main/input_validation)

## Page class attributes

| Attribute | Default | Description |
|-----------|---------|-------------|
| `allow_back` | `False` | Show a "Back" button |
| `template` | Matching app `.html` file, then `.md` | Custom template path relative to the project root |
| `keep_values`{ .text-nowrap } | `False` | Re-populate form from player data on re-render |

## Wait page methods

### `GroupCreatingWait`

| Attribute/Method | Description |
|------------------|-------------|
| `group_size` | Required. Number of players per group |
| `after_grouping(page, group)` | Called once when the group forms |

### `SynchronizingWait`

| Attribute/Method | Description |
|------------------|-------------|
| `synchronize` | `"group"` (default) or `"session"` |
| `all_here(page, group)` | Called when all group members arrive |
| `all_here(page, session)` | Called when all session members arrive (if `synchronize = "session"`) |
| `wait_for(page, player)` | Override to customize who to wait for |

---

Source: https://uproot.science/reference/api.md

# API reference

This page documents the key functions and classes available when building uproot experiments. All of these are available automatically in app modules via `from uproot.smithereens import *` and `from uproot.fields import *`.

## Page types

### `Page`

The standard page type. Displays a template and optionally collects form data.

```python
class MyPage(Page):
    fields = dict(participant_name=StringField(label="Your name"))

    @classmethod
    def templatevars(page, player):
        return dict(greeting="Hello")
```

See [Page methods](https://uproot.science/reference/page-methods.md) for all available methods.

### `NoshowPage`

A page that runs logic without displaying anything to the participant.

```python
class Calculate(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        player.payoff = player.correct * 10
```

### `GroupCreatingWait`

A wait page that forms groups of participants.

```python
class GroupPlease(GroupCreatingWait):
    group_size = 2

    @classmethod
    def after_grouping(page, group):
        for player, role in zip(group.players, ["proposer", "responder"]):
            player.role = role
```

### `SynchronizingWait`

A wait page that synchronizes group or session members.

```python
class Sync(SynchronizingWait):
    @classmethod
    def all_here(page, group):
        for player in group.players:
            set_payoff(player)
```

Set `synchronize = "session"` to synchronize across the entire session.

## SmoothOperators

### `RandomOrder`

Shuffles pages into a random order per participant.

```python
page_order = [RandomOrder(TaskA, TaskB, TaskC)]
```

### `Bracket`

Groups pages as an atomic unit within `RandomOrder` or `Between`. SmoothOperators take only pages and other SmoothOperators, so use `Bracket` instead of a list.

```python
page_order = [RandomOrder(Bracket(Intro1, Task1), Bracket(Intro2, Task2))]
```

### `Rounds`

Repeats pages a fixed number of times. Sets `player.round` (1-indexed), `player.block`, and `player.round_nested`.

```python
page_order = [Rounds(Decision, Feedback, n=5)]
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `n` | `int` | — | Number of rounds (0 or more) |
| `block` | `str` or `None` | `None` | [Block](https://uproot.science/building/operators.md#blocks) name (a valid Python identifier); required if an app can run several loops |

### `Repeat`

Repeats pages indefinitely until `player.add_round = False`. Sets the same fields as `Rounds`.

```python
page_order = [Repeat(Trial, Check), Done]
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `block` | `str` or `None` | `None` | [Block](https://uproot.science/building/operators.md#blocks) name (a valid Python identifier); required if an app can run several loops |

### `Between`

Randomly selects one option per participant. Appends the pages of the selected option to `player.between_showed`, one list per `Between` (see [Tracking which condition was shown](https://uproot.science/building/operators.md#tracking-which-condition-was-shown)).

```python
page_order = [Between(Treatment, Control)]
```

## `PlayerContext`

Base class for reusable computed properties. During page execution, properties on the current app’s `Context` class are available in Python and templates as `player.context.*`.

```python
class Context(PlayerContext):
    @property
    def earnings(self):
        return self.player.payoff * C.EXCHANGE_RATE
```

| Access | How the class is selected | Use it when |
|--------|---------------------------|-------------|
| `player.context` | uproot reads `player.app` and uses that app’s `Context` class | A page method or template is running inside the app |
| `Context(player)`{ .text-nowrap } | Python resolves the `Context` name directly in the app module | Code is outside page execution, such as a callable `page_order(player=)` or `digest(session)` |

`player.context` is `None` when the participant has no active app or the active app defines no `Context` class. See [The PlayerContext class](https://uproot.science/building/pages.md#the-playercontext-class) for the lifecycle details and examples.

## Real-time functions

### `notify`

Send data from one player to one or more recipients.

```python
notify(sender, recipients, data, event="EventName", where=...)
```

| Parameter | Description |
|-----------|-------------|
| `sender` | The sending player (determines page context) |
| `recipients` | Player, `StorageBunch`, or list of players |
| `data` | Any JSON-serializable data |
| `event` | Custom event name (default: `"_uproot_Received"`) |
| `where` | Recipient page index; `...` delivers regardless of page |

### `send_to`

Send data to one or more players without a sender context.

```python
send_to(recipients, data, event="EventName", where=...)
```

### `send_to_one`

Send data to a single player.

```python
send_to_one(player, data, event="EventName", where=...)
```

### `reload`

Force a player’s browser to reload the current page.

```python
reload(player)
```

### `move_to_page`

Move a player forward to the next occurrence of a page in their page order, skipping the pages in between.

```python
move_to_page(player, TargetPage, reload_=True)
```

- If the target lies inside [SmoothOperators](https://uproot.science/building/operators.md), uproot sets them up on the way, as if the player had arrived normally: the target’s round starts, a `RandomOrder` is shuffled (pages that end up before the target are skipped), and a `Between` makes its selection. Moving into a later app starts that app.
- SmoothOperators that the move skips entirely are left out. For example, moving from round 2 to a page after the `Rounds` keeps `player.round == 2`.
- It raises a `ValueError` if the target is not ahead of the player, or if a `Between` on the way does not select the target. The player then stays where they are, and nothing about them changes, not even in the data.

### `move_to_end`

Move a player past all remaining pages.

```python
move_to_end(player, reload_=True)
```

### `spawn`

Run an async function as a supervised background task. The task keeps running after the calling method returns. Exceptions are logged, and all spawned tasks are cancelled at server shutdown. Tasks do not survive server restarts.

```python
spawn(my_coroutine(player))
```

Because spawned tasks run outside a page method, wrap data mutations in a context manager (`with player as p:`). See [Background tasks](https://uproot.science/multiplayer/real-time.md#background-tasks-with-spawn) for examples.

## Dropout functions

### `watch_for_dropout`

Monitor a player for disconnection and call a handler when they go offline.

```python
watch_for_dropout(player, handler, tolerance=30.0)
```

### `mark_dropout`

Add a player to the manual dropout set used by the dropout watcher.

```python
mark_dropout(player_pid)
```

## Group functions

### `create_group`

Create a group from a list of players.

```python
gid = create_group(session, [player1, player2], gname="custom_name", overwrite=False)
```

### `create_groups`

Create multiple groups at once.

```python
gids = create_groups(session, [[p1, p2], [p3, p4]])
```

### `add_to_group`

Add players to an existing group.

```python
add_to_group(group, player)
add_to_group(group, [player1, player2])
add_to_group(group, player, overwrite=True)  # reassign players already in a group
```

## The `@live` decorator

Makes a page method callable from JavaScript via WebSocket.

```python
class MyPage(Page):
    @live
    async def my_method(page, player, arg: str):
        return {"result": arg.upper()}
```

Called from JavaScript:

```javascript
uproot.invoke("my_method", "hello").then(data => console.log(data.result));
```

## App HTTP APIs

Apps may expose HTTP endpoints by defining `api(request, session)` or `api2(request, session)`.

### Operator APIs

`api` is served at `/api/{appname}/{sname}/` and requires an `Authorization: Bearer ...` header matching one of the server API keys in `upd.API_KEYS`. Use it for programmatic access that should be limited to trusted operators or scripts.

### Participant APIs

`api2` is served at `/api2/{appname}/{sname}/`. It is for participant-browser assets and callbacks, such as loading a captcha image. Direct requests remain public: anyone who knows the app name and session name can call the endpoint without participant credentials.

!!! warning "Trust boundary"
    Treat an `api2` request as public unless the callback receives an authenticated `player`. Participant authentication identifies one participant; it does not grant operator privileges. Do not expose admin-only data or actions through `api2`. Use `api` for those.

#### Identifying the calling participant

This is an advanced feature. Use it when browser code must call an HTTP endpoint as the participant who is viewing the current page—for example, to upload data with `fetch()` while saving the result on that participant.

Define `player` as an optional callback argument and reject public calls explicitly:

```python
from fastapi import HTTPException

async def api2(request, session, player=None):
    if player is None:
        raise HTTPException(status_code=403, detail="Participant required")

    data = await request.json()
    player.last_action = data["action"]
    return {"saved": True}
```

Call the endpoint from a participant template with `uproot.api2()`:

```html
<script>
async function saveAction(action) {
    const response = await uproot.api2("my_app", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({action}),
    });

    if (!response.ok) {
        throw new Error(`Request failed with status ${response.status}`);
    }

    return response.json();
}
</script>
```

`uproot.api2(appname, options)` accepts the usual [`fetch()` options](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#parameters) and returns its `Promise<Response>`. It builds the URL for the current session and adds the current participant’s name and CSRF proof as request headers. It also defaults `credentials` to `"same-origin"`. Do not construct or copy these authentication headers yourself.

The server handles the three credential states differently:

| Credential state | Result |
|------------------|--------|
| Both headers absent | The public callback runs with `request` and `session`; the `player` keyword is omitted |
| Both headers present and valid | The callback also receives the authenticated `player` |
| One header missing, either header invalid, or the CSRF proof non-ASCII | The server returns 403 without running the callback |

For an authenticated call, `request.state.uproot_player` refers to the same object as `player`. For a public call, it is `None`. The `session` and authenticated `player` remain inside their storage contexts while the callback runs, so assignments such as `player.last_action = ...` are saved normally.

!!! warning "Keep the optional default"
    The server omits the `player` keyword entirely for a public call. Write `player=None` if a callback may receive either kind of request. Merely adding a `player` parameter does not protect the endpoint—check for `None` and return an error as shown above.

!!! warning "Use only the authenticated player"
    Do not identify a participant from a username in the query string or request body. Those values are controlled by the caller. Read and modify only the authenticated `player` supplied by uproot.

## Utility functions

### `rng`

Create an independent `random.Random` generator with an OS-random seed. Store it on a player if you need to resume the same stream later.

```python
player.treatment = rng().choice(["A", "B"])
player.rng = rng()
player.bomb = player.rng.randint(1, 25)
```

See [Random numbers](https://uproot.science/building/data.md#random-numbers).

### `uuid`

Return a UUID (uuid7 if the Python version provides it, otherwise uuid4).

```python
player.token = str(uuid())
```

### `cu`

Create a `Decimal` from a string. Shorthand for `Decimal(value)`.

```python
endowment = cu("10.00")
```

### `safe`

Mark a string as HTML-safe (will not be escaped in templates).

```python
label=StringField(label=safe("Enter a value <b>in euros</b>"))
```

### `data_uri`

Convert binary data to a data URI string for embedding in HTML.

```python
player.image_uri = data_uri(image_bytes)
```

The MIME type is inferred from common file signatures such as JPEG, PNG, GIF, PDF, ZIP, and MP4. Unknown data is emitted as `application/octet-stream`.

## Identifier types

| Type | Description |
|------|-------------|
| `PlayerIdentifier` | Uniquely identifies a player |
| `SessionIdentifier` | Uniquely identifies a session |
| `GroupIdentifier` | Uniquely identifies a group |
| `ModelIdentifier` | Uniquely identifies a model |

These are used internally and in [custom data models](https://uproot.science/advanced/models.md).

## StorageBunch

A collection of storage objects (players, groups) with query methods.

| Method | Description |
|--------|-------------|
| `len(bunch)` | Number of items |
| `bunch[i]` | Get by index |
| `item in bunch` | Membership test |
| `bunch.filter(*.comparisons)`{ .text-nowrap } | Filter by field values using `_` |
| `bunch.find_one(**kwargs)` | Find exactly one match |
| `bunch.assign(key, values)` | Set a field on all items |
| `bunch.each(*keys)` | Extract fields from all items |
| `bunch.apply(fn)` | Apply a function to all items |

### The `_` field referent

`_` is a `FieldReferent`: a placeholder that stands for each item in the collection. It builds lazy comparison objects that are evaluated per item during `filter()`.

```python
# _ is auto-imported via `from uproot.smithereens import *`
cooperators = group.players.filter(_.cooperate == True)
high_earners = session.players.filter(_.payoff > 10)

# Multiple conditions (all must match)
eligible = session.players.filter(_.present == True, _.age >= 18)

# Chained attribute access
same_round = session.players.filter(_.group.round == 3)
```

Supported operators: `==`, `!=`, `>`, `>=`, `<`, `<=`. Bare `_.field` (without an operator) tests for truthiness.

See [Filtering with `_`](https://uproot.science/multiplayer/groups.md#filtering-with-_) for more examples.

---

Source: https://uproot.science/reference/admin-api.md

# Admin API reference

The Admin REST API provides programmatic access to manage uproot experiments.
All endpoints are located under `/admin/api/v1/` and require Bearer token authentication, except where noted.

!!! note "Generated from FastAPI OpenAPI"
    This page is generated by `scripts/generate_admin_api_docs.py` from FastAPI’s OpenAPI schema. A running uproot server also exposes FastAPI’s live schema at `/openapi.json` and interactive documentation at `/docs` and `/redoc`.

## Authentication

All requests must include an `Authorization` header with a valid API token:

```http
Authorization: Bearer YOUR_API_TOKEN
```

To enable API access, add one or more tokens in your project’s `main.py`:

```python
upd.API_KEYS.add("YOUR_API_TOKEN")
```

## Errors

Errors are returned as JSON with a `detail` field:

```json
{"detail": "Session not found"}
```

These status codes are shared by many endpoints and are not repeated below:

| Status | Meaning |
|--------|---------|
| `400` | The request is invalid, e.g. an unknown configuration or a duplicate name |
| `401` | The Bearer token is missing or invalid |
| `404` | A session, player, room, configuration, digest, or pipeline named in the request does not exist |
| `422` | Parameters or request body do not match the expected types; `detail` is a list of problems |

The **Responses** tables below list successful responses and errors specific to one endpoint.

## CLI access

The `uproot api` command calls these endpoints from the command line:

```bash
export UPROOT_API_KEY="YOUR_API_TOKEN"
uproot api sessions
uproot api sessions/mysession
uproot api rooms
uproot api rooms/waiting-room
uproot api sessions/mysession/online-players
uproot api sessions/mysession/players/ABC
uproot api -X POST sessions -d '{"config": "myconfig", "n_players": 4}'
uproot api -X PATCH sessions/mysession/active -d '{"active": false}'
uproot api -X POST sessions/mysession/players/advance -d '{"unames": ["ABC"]}'
```

## Dashboard and configurations

### `GET /admin/api/v1/configs/`

List all available configurations and apps.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/configs/{cname}/`

Get details for a configuration.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `cname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/dashboard/`

Get the same top-level aggregate shown on the admin dashboard.

!!! note
    The response contains `uproot_version`, `nudge_announcements` (whether the admin UI currently nudges you to check announcements), `configs`, `rooms`, and `active_sessions`.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

## Sessions

### `GET /admin/api/v1/sessions/`

List all sessions with their metadata.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/`

Create a new session with the specified configuration and players.

**Request body** (`SessionCreate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `config`{ .text-nowrap } | string | Yes | Configuration name |
| `n_players`{ .text-nowrap } | integer | Yes | Number of players to create (min: `0.0`) |
| `sname`{ .text-nowrap } | string or null | No | Custom session name (auto-generated if omitted) |
| `unames`{ .text-nowrap } | array[string] or null | No | Custom usernames for players |
| `settings`{ .text-nowrap } | object or null | No | Session settings |
| `simulate`{ .text-nowrap } | boolean | No | Enable response simulation (default: `False`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `201` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/`

Get detailed information about a specific session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/active/`

Set the active status of a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`SessionActive`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `active`{ .text-nowrap } | boolean | Yes | Whether the session is active |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/description/`

Update the description of a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`DescriptionUpdate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description`{ .text-nowrap } | string | No | New description (empty to clear) (default: ``) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/initialize/`

Run new_session callbacks for a session that has not been initialized.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/settings/`

Update the settings of a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`SettingsUpdate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `settings`{ .text-nowrap } | object | Yes | New settings |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/testing/`

Set the testing mode of a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`SessionTesting`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `testing`{ .text-nowrap } | boolean | Yes | Whether testing mode is enabled |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

## Players

### `GET /admin/api/v1/sessions/{sname}/multiview/`

Get player metadata needed to reproduce the admin multiview.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/online-players/`

Get online status and info for all players in a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/players/`

Get specified fields for all players in a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `fields`{ .text-nowrap } | array[string] | No |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/advance/`

Advance specified players by one page.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/dropout/`

Mark specified players as manually dropped out.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/end/`

Move specified players to the end of the experiment.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/players/fields/`

Set arbitrary fields on specified players.

!!! note
    Field names must be valid Python identifiers (letters, digits, and underscores, not starting with a digit). They are checked before anything is written: if any name is invalid, the request fails with `400` and no player is changed.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersFields`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `fields`{ .text-nowrap } | object | Yes | Fields to set |
| `reload`{ .text-nowrap } | boolean | No | Whether to trigger page reload (default: `False`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/group/`

Manage group assignments for selected players.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersGroup`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `action`{ .text-nowrap } | string | Yes | Grouping action: same_group, reset, or by_size |
| `group_size`{ .text-nowrap } | integer | No | Group size for by_size (min: `1.0`; default: `1`) |
| `shuffle`{ .text-nowrap } | boolean | No | Shuffle players before grouping (default: `False`) |
| `reload`{ .text-nowrap } | boolean | No | Whether to trigger page reload (default: `False`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/initialize/`

Run new_player callbacks for players that have not been initialized.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/message/`

Send an admin message to specified players.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayerMessage`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `message`{ .text-nowrap } | string | Yes | Message to send |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/redirect/`

Redirect specified players to an external URL.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayerRedirect`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `url`{ .text-nowrap } | string | Yes | URL to redirect to (must start with http:// or https://) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/reload/`

Force page reload for specified players.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/revert/`

Revert specified players by one page.

!!! note
    Players cannot be reverted past the start of a round (`Rounds` or `Repeat`) or of an app, or from the end of the experiment, because `player.round` and `player.app` would not go back with them. All players are checked first: if any of them cannot be reverted, the request fails with `400` and no player is moved.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersAction`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames to act on (min items: `1`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/timeout/`

Adjust the page timeout for specified players.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayerTimeout`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `delta`{ .text-nowrap } | number | No | Timeout adjustment in seconds (default: `60.0`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/players/{uname}/`

Get specified fields for one player in a session.

!!! note
    Pass `fields` repeatedly to choose fields, e.g. `?fields=id&fields=page_order`. Omitting it returns the same default fields as the all-players endpoint.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `uname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `fields`{ .text-nowrap } | array[string] | No |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

## Admin chat

### `GET /admin/api/v1/sessions/{sname}/admin-chat/`

Summarize admin chat state for each player in a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/admin-chat/`

Send an admin chat message to multiple players at once.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`AdminchatBroadcast`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `message`{ .text-nowrap } | string | Yes | Message to send |
| `enable_replies`{ .text-nowrap } | boolean or null | No | Optionally update whether all recipients may reply |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/players/admin-chat/replies/`

Enable or disable admin chat replies for multiple players.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Request body** (`PlayersChatReplies`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `unames`{ .text-nowrap } | array[string] | Yes | List of usernames (min items: `1`) |
| `enabled`{ .text-nowrap } | boolean | Yes | Whether player replies are enabled |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/players/{uname}/admin-chat/`

Get admin chat metadata and transcript for one player.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `uname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/sessions/{sname}/players/{uname}/admin-chat/`

Send an admin chat message to one player.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `uname`{ .text-nowrap } | string | Yes |  |

**Request body** (`AdminchatMessage`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message`{ .text-nowrap } | string | Yes | Message to send |
| `enable_replies`{ .text-nowrap } | boolean or null | No | Optionally update whether the player may reply |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/sessions/{sname}/players/{uname}/admin-chat/replies/`

Enable or disable a player’s ability to reply in admin chat.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `uname`{ .text-nowrap } | string | Yes |  |

**Request body** (`AdminchatReplies`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `enabled`{ .text-nowrap } | boolean | Yes | Whether player replies are enabled |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

## Data export

### `GET /admin/api/v1/sessions/{sname}/data/`

Get all session data in display format, optionally filtered by timestamp.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `since`{ .text-nowrap } | number | No | Only return data updated since this epoch timestamp |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/data/export/`

Download a ZIP briefcase of session data, as in the admin UI. The briefcase always contains the ultralong, sparse, and latest formats, page times, a data dictionary, and integrity checksums. Passing gvar adds a grouped latest format on top.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `filetype`{ .text-nowrap } | string | No | Export file type: csv or jsonl |
| `gvar`{ .text-nowrap } | array[string] | No | Group-by variables for the optional grouped latest format |
| `filters`{ .text-nowrap } | boolean | No | Apply reasonable filters |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/zip`{ .text-nowrap } | ZIP briefcase |

---

### `GET /admin/api/v1/sessions/{sname}/data/jsonl/`

Download session data as JSONL (streaming).

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `format`{ .text-nowrap } | string | No | Export format: ultralong, sparse, or latest |
| `gvar`{ .text-nowrap } | array[string] | No | Group-by variables |
| `filters`{ .text-nowrap } | boolean | No | Apply reasonable filters |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/jsonl`{ .text-nowrap } | JSONL stream |

---

## Digests and pipelines

### `GET /admin/api/v1/sessions/{sname}/digests/`

Run all available app digests for a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/digests/html/`

Render the app-authored AdminDigest.html fragments shown by the admin UI.

!!! note
    `apps` lists every app with a digest. `html` only contains entries for apps that provide an `AdminDigest.html` template.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/digests/{appname}/`

Run one app digest for a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `appname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/digests/{appname}/html/`

Render one app-authored AdminDigest.html fragment.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `appname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `text/html`{ .text-nowrap } | Rendered fragment |

---

### `GET /admin/api/v1/sessions/{sname}/pipelines/`

List apps that provide a pipeline for a session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/pipelines/html/`

Render app-authored AdminPipeline.html fragments shown by the admin UI.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/sessions/{sname}/pipelines/{appname}/html/`

Render one app-authored AdminPipeline.html fragment.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `appname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `text/html`{ .text-nowrap } | Rendered fragment |

---

### `GET /admin/api/v1/sessions/{sname}/pipelines/{appname}/runs/`

Run an app pipeline without custom JSON data, matching the admin UI button.

!!! note
    This endpoint never passes data to the pipeline, even if you send a request body. Use `POST` to pass data.

!!! note
    If the pipeline returns a custom data export, it is downloaded as CSV or JSONL, depending on `filetype`. Any other result is returned as plain text. `filetype` must be `csv` or `jsonl`; any other value fails with `400` before the pipeline runs.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `appname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `filetype`{ .text-nowrap } | string | No | Export file type: csv or jsonl |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `text/plain`{ .text-nowrap } | Pipeline result |
| `200` | `text/csv`{ .text-nowrap } | Custom data export with `filetype=csv` |
| `200` | `application/jsonl`{ .text-nowrap } | Custom data export with `filetype=jsonl` |

---

### `POST /admin/api/v1/sessions/{sname}/pipelines/{appname}/runs/`

Run an app pipeline, optionally passing a JSON request body.

!!! note
    This endpoint accepts an optional JSON request body. The decoded body is passed to the app’s `pipeline()` as `data` (`None` if the body is empty). A body that is not valid JSON fails with `400`.

!!! note
    If the pipeline returns a custom data export, it is downloaded as CSV or JSONL, depending on `filetype`. Any other result is returned as plain text. `filetype` must be `csv` or `jsonl`; any other value fails with `400` before the pipeline runs.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sname`{ .text-nowrap } | string | Yes |  |
| `appname`{ .text-nowrap } | string | Yes |  |

**Query parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `filetype`{ .text-nowrap } | string | No | Export file type: csv or jsonl |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `text/plain`{ .text-nowrap } | Pipeline result |
| `200` | `text/csv`{ .text-nowrap } | Custom data export with `filetype=csv` |
| `200` | `application/jsonl`{ .text-nowrap } | Custom data export with `filetype=jsonl` |

---

## Rooms

### `GET /admin/api/v1/rooms/`

List all rooms with their configuration.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/rooms/`

Create a new room.

**Request body** (`RoomCreate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name`{ .text-nowrap } | string | Yes | Room name (min length: `1`) |
| `config`{ .text-nowrap } | string or null | No | Default configuration for sessions |
| `labels`{ .text-nowrap } | array[string] or null | No | Allowed labels for participants |
| `capacity`{ .text-nowrap } | integer or null | No | Maximum capacity |
| `open`{ .text-nowrap } | boolean or null | No | Whether the room is open for joining (defaults to true if sname is given, false otherwise) |
| `sname`{ .text-nowrap } | string or null | No | Associated session name |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `201` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/rooms/{roomname}/`

Get detailed information about a specific room.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/rooms/{roomname}/`

Update room settings, optionally associating an existing session (only when no session is currently associated).

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Request body** (`RoomUpdate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `config`{ .text-nowrap } | string or null | No | Default configuration |
| `labels`{ .text-nowrap } | array[string] or null | No | Allowed labels |
| `capacity`{ .text-nowrap } | integer or null | No | Maximum capacity |
| `open`{ .text-nowrap } | boolean or null | No | Whether the room is open (defaults to true if sname is given, otherwise unchanged) |
| `sname`{ .text-nowrap } | string or null | No | Existing session to associate with the room |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `DELETE /admin/api/v1/rooms/{roomname}/`

Delete a room (only when no session is associated).

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/rooms/{roomname}/capacity/`

Set a room’s capacity, even while a session is associated.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Request body** (`RoomCapacity`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `capacity`{ .text-nowrap } | integer or null | Yes | Maximum capacity (null for unlimited) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/rooms/{roomname}/close/`

Close a room, optionally disassociating its session first.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Request body** (`RoomClose`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `disassociate`{ .text-nowrap } | boolean | No | If true, disassociate the session before closing (default: `False`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/rooms/{roomname}/online/`

Get online status for a room’s waiting area.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `PATCH /admin/api/v1/rooms/{roomname}/open/`

Set a room’s open status without requiring disassociation.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Request body** (`RoomOpen`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `open`{ .text-nowrap } | boolean | Yes | Whether the room should be open |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/rooms/{roomname}/sessions/`

Create a new session within a room, by default using the room’s config.

!!! note
    If `config` is omitted, the room’s default configuration is used. If the room has none either, the request fails with `400`.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Request body** (`RoomSessionCreate`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `config`{ .text-nowrap } | string or null | No | Configuration name (defaults to the room’s config) |
| `n_players`{ .text-nowrap } | integer | Yes | Number of players (min: `0.0`) |
| `assignees`{ .text-nowrap } | array[string] or null | No | Labels to assign to players |
| `settings`{ .text-nowrap } | object or null | No | Session settings |
| `sname`{ .text-nowrap } | string or null | No | Custom session name |
| `unames`{ .text-nowrap } | array[string] or null | No | Custom usernames |
| `no_grow`{ .text-nowrap } | boolean | No | Lock capacity to n_players (default: `False`) |
| `simulate`{ .text-nowrap } | boolean | No | Enable response simulation (default: `False`) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `201` | `application/json`{ .text-nowrap } | Successful response |

---

### `DELETE /admin/api/v1/rooms/{roomname}/sessions/`

Disassociate a room from its current session.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roomname`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

## System

### `GET /admin/api/v1/announcements/`

Fetch announcements from upstream.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/announcements/dismiss/`

Silence the admin UI’s nudge to check announcements.

!!! note
    The nudge stays silent for ten years. The dashboard’s `nudge_announcements` reflects this.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/auth/login/`

Create the same browser admin session token as submitting /admin/login/.

!!! note
    This endpoint does not require a Bearer token.

!!! note
    Login attempts are rate-limited per IP address. After 50 failed attempts within one hour, the IP is blocked for six hours. Requests from localhost are exempt.

**Request body** (`AuthLogin`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `user`{ .text-nowrap } | string | No | Admin username (default: `admin`) |
| `pw`{ .text-nowrap } | string | No | Admin password (default: ``) |
| `token`{ .text-nowrap } | string | No | Auto-login token (default: ``) |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `201` | `application/json`{ .text-nowrap } | Successful response |
| `401` | `application/json`{ .text-nowrap } | Invalid admin credentials |
| `429` | `application/json`{ .text-nowrap } | Too many failed login attempts |

---

### `POST /admin/api/v1/auth/logout-all/`

Revoke all browser admin sessions for the token’s user, matching /admin/status/logout-all/.

!!! note
    This revokes browser admin-login sessions. It does not revoke API keys.

**Request body** (`AuthToken`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `auth_token`{ .text-nowrap } | string | Yes | Value of the uauth browser cookie |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `POST /admin/api/v1/auth/logout/`

Revoke one browser admin session token, matching /admin/logout/.

!!! note
    This revokes browser admin-login sessions. It does not revoke API keys.

**Request body** (`AuthToken`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `auth_token`{ .text-nowrap } | string | Yes | Value of the uauth browser cookie |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/auth/sessions/`

Get information about active authentication sessions.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `DELETE /admin/api/v1/auth/sessions/{user}/`

Revoke all browser admin sessions for one user.

!!! note
    This revokes browser admin-login sessions for the named user. It does not revoke API keys.

**Path parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `user`{ .text-nowrap } | string | Yes |  |

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

### `GET /admin/api/v1/database/dump/`

Download a complete machine-readable database dump.

!!! note
    The response is a gzip-compressed MessagePack dump intended for `uproot restore`, not JSON.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/gzip`{ .text-nowrap } | Gzip-compressed database dump |

---

### `GET /admin/api/v1/praise/`

Fetch the same praise text shown by the admin UI.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `text/plain`{ .text-nowrap } | Praise text |
| `502` | `application/json`{ .text-nowrap } | Praise could not be fetched from upstream |

---

### `GET /admin/api/v1/project/archive/`

Download a reproducible .tar.gz archive of the project code, excluding files ignored by .gitignore.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/gzip`{ .text-nowrap } | Gzip-compressed tar archive of the project |

---

### `GET /admin/api/v1/status/`

Get status information.

**Responses**:

| Status | Content | Description |
|--------|---------|-------------|
| `200` | `application/json`{ .text-nowrap } | Successful response |

---

---

Source: https://uproot.science/reference/cli.md

# CLI commands

uproot provides a command-line interface for managing projects and running experiments. Commands are split into two categories: **global commands** that work anywhere, and **project commands** that must be run inside a project directory.

## Global commands

### uproot setup

Create a new uproot project.

```bash
uproot setup my_project
```

| Option | Description |
|--------|-------------|
| `--force` | Overwrite existing files |
| `--minimal` | Create a minimal project without the example app |
| `--no-example`{ .text-nowrap } | Skip the example app |

### uproot api

Access the [Admin REST API](https://uproot.science/reference/admin-api.md) from the command line.

```bash
# List sessions
uproot api sessions

# Get session details
uproot api sessions/mysession

# Create a session
uproot api -X POST sessions -d '{"config": "myconfig", "n_players": 4}'

# Access a remote server
uproot api -u https://example.com/ sessions
```

| Option | Default | Description |
|--------|---------|-------------|
| `--url`, `-u` | `http://127.0.0.1:8000/` | Server base URL |
| `--auth`, `-a` | `$UPROOT_API_KEY` | Bearer token |
| `--method`, `-X` | `GET` | HTTP method |
| `--data`, `-d` | — | JSON request body |

### uproot check-translations

Check the [translations](https://uproot.science/building/pages.md#translations) of a project. For each language that has a YAML file in the project, it lists every phrase that has no translation, either in the project or among uproot’s built-in translations.

```bash
# Check the project in the current directory
uproot check-translations

# Check another directory, such as a single app
uproot check-translations my_project/my_app
```

It finds phrases in `{% translate %}` blocks, in `_("...")` calls in templates and JavaScript, in `lookup("...")` calls, and in the labels, descriptions, and choices of form fields. It also points out phrases that differ from a translated one only in quotes or spacing. uproot translates all form field texts, even those not meant to be translated, such as the names of languages in a language selector. So a field text only counts as missing if it is translated for some languages but not for others. The command exits with status 1 if phrases are missing.

| Option | Description |
|--------|-------------|
| `--untranslated` | List form field texts that have no translation in any language |

### uproot --version

Show the installed uproot version.

### uproot --copyright

Show copyright information.

## Project commands

These commands must be run from inside an uproot project directory (where `main.py` is located).

### uproot run

Start the development server.

```bash
uproot run
```

| Option | Default | Description |
|--------|---------|-------------|
| `-h`, `--host` | `127.0.0.1` | Host to bind to |
| `-p`, `--port` | `8000` | Port to listen on |
| `--unsafe` | — | Disable HTTPS requirement (for development behind certain proxies) |
| `--public-demo`{ .text-nowrap } | — | Run in public demo mode (restricted admin) |

### uproot start

Start the development server with a participant link ready to go. This is the fastest way to try an experiment: instead of creating a session in the admin by hand, uproot creates an open [room](https://uproot.science/running/rooms.md) for your config and prints its URL when the server starts.

```bash
uproot start myconfig
```

```
Room:
     http://127.0.0.1:8000/room/quick1/
```

Open the room URL in one browser tab per participant. Each tab joins the session as a new player. If your project has exactly one config, you can omit the config name and just run `uproot start`.

The rooms created this way are ordinary rooms named `quick1`, `quick2`, and so on; each `uproot start` creates a new one, and they appear under **Rooms** in the admin like any other.

It accepts the same `--host`, `--port`, `--unsafe`, and `--public-demo`
options as `uproot run`, plus:

| Option | Description |
|--------|-------------|
| `CONFIG` or `--config`{ .text-nowrap } | Config to use (optional if the project has exactly one config) |
| `--simulate`{ .text-nowrap } | Enable the app’s `simulate.js` responses for the quick room’s session (see [App testing](https://uproot.science/running/admin.md#app-testing)) |

### uproot reset

Reset the database, deleting all sessions and data.

```bash
uproot reset
```

| Option | Description |
|--------|-------------|
| `--yes` | Skip the confirmation prompt |

!!! warning
    This permanently deletes all experiment data. Use `uproot dump` first if you need a backup.

### uproot dump

Dump the entire database to a file.

```bash
uproot dump --file backup.msgpack.gz
```

| Option | Description |
|--------|-------------|
| `--file` | Path to the output file (required) |

The output is gzip-compressed automatically.

### uproot restore

Restore a database from a dump file.

```bash
uproot restore --file backup.msgpack.gz
```

| Option | Description |
|--------|-------------|
| `--file` | Path to the dump file (required) |
| `--yes` | Skip the confirmation prompt |

`uproot restore` accepts both gzip-compressed and uncompressed dump files.

### uproot new

Create a new app in the current project.

```bash
uproot new my_app
```

| Option | Description |
|--------|-------------|
| `--minimal`{ .text-nowrap } | Create a minimal app without example code |

This creates a new directory with `__init__.py`, the page templates, and `simulate.js`. Without `--minimal`, the new app is the two-player prisoner’s dilemma from the [tutorial](https://uproot.science/getting-started/tutorial.md), ready to run. With `--minimal`, it has a single placeholder page. You still need to register the app in `main.py` using `load_config`.

### uproot newpage

Create a new page in an existing app.

```bash
uproot newpage my_app MyNewPage
```

This creates a new HTML template file for the page and adds the page class to the app’s `__init__.py`.

### uproot examples

Download the example experiments from GitHub.

```bash
uproot examples
```

Downloads and extracts all example apps into the current directory. Useful for learning and reference.

### uproot announcements

Check whether the uproot developers have posted important announcements for your version.

```bash
uproot announcements
```

This is the only command that contacts uproot.science, and only when you run it. It respects `upd.UPSTREAM = False` in `main.py` (see [Announcements](https://uproot.science/running/admin.md#announcements)).

### uproot deployment

Print the currently set `UPROOT_*` environment variables.

```bash
uproot deployment
```

## Using with uv

If you are using uv (recommended), prefix commands with `uv run`:

```bash
uv run uproot run
uv run uproot new my_app
uv run uproot dump --file backup.msgpack.gz
```
