# The worker's page: chat, upload and Run behind one password (/docs/agent/the-owner-page)

---
type: Procedure
title: "The worker's page: chat, upload and Run behind one password"
description: "What every worker built from this record is handed over as, at demo and at production depth: one web page with a password, an upload for the day's files, a Run button whose numbers come from code, and a chat box where the person asks in their own words and language, with off-topic requests politely refused and a daily limit that protects the model quota; the template, the instruction lines, the tests, and what a live check on 2026-10-12 showed."
status: stable
order: 22
generated: { by: "process:claude-code", at: 2026-10-12T07:57:06Z }
verified:
  - { by: "human:rrizwan98", at: 2026-10-12T07:57:06Z }
sources:
  - id: raza-mortgage-demo
    title: "Owner's review of a mortgage follow-up worker built from this record and its skill as a public demo on dummy data, 12 October 2026 (attestation)"
    resource: "attestation: Raza Rizwan, owner; review conducted with process:claude-code on 2026-10-12 of a demo build made by a coding agent from this record and build-your-agent 2.1 on invented pilot data; not client work"
  - id: raza-page-check
    title: "Template check of the worker's page, 12 October 2026: four offline tests and five live questions on gemini-3.1-flash-lite (attestation)"
    resource: "attestation: run by process:claude-code for Raza Rizwan on 2026-10-12 with openai-agents 0.23.1, fastapi and python-multipart, on invented data; not client work"
  - id: fastapi-files
    title: "Request Files: UploadFile, and python-multipart required to receive uploaded files. FastAPI documentation"
    resource: https://fastapi.tiangolo.com/tutorial/request-files/
  - id: fastapi-cookies
    title: "Response Cookies: setting a cookie on the Response parameter. FastAPI documentation"
    resource: https://fastapi.tiangolo.com/advanced/response-cookies/
  - id: sdk-models
    title: "Models: OpenAI-compatible endpoints and tracing without an OpenAI key. OpenAI Agents SDK documentation"
    resource: https://openai.github.io/openai-agents-python/models/
ksor:
  audience: [public]
  owner: human:rrizwan98
  approval: { by: "human:rrizwan98", at: 2026-10-12T07:57:06Z }
trust_tier: human-reviewed
build_id: sha256:4d4bff09bd0a1dcff0a0a50f20f7ef514fc471e2b1f9e81e0e7b6150b93c0793
dirty: true
---

A worker is not finished when it answers a terminal; it is finished when
the person who owns the work can open it and use it without help. A test
build from this record ended as an API behind a bearer token, tested from a
terminal, with a documentation page that could not send the token: correct,
and unusable by the person it was built for. [^raza-mortgage-demo] So every
worker built from this record, at [demo or production depth](demo-or-production.md),
is handed over as one page, and the rest of this page is that page.

## What the person sees

Four things, top to bottom, and nothing else:

| On the page | What it does | Who decides |
| --- | --- | --- |
| **Password** | One box; nothing below it works until it is right | The owner chooses it; it lives only in the deploy's environment variables |
| **1. Add today's files** | Uploads the day's exports or documents to the worker | The person |
| **2. Run** | Runs the workflow in code and shows the day's report | Code, never the model: every count, date and ranking is computed |
| **3. Ask in your own words** | A chat box: the person asks anything about the work, in any language, and the agent answers | The agent, inside the rules below, with a daily limit shown under the box |

The page replaces the documentation page as the thing a person is given. A
production worker keeps its API for other systems, behind its own
authentication; the person still gets the page.

## The four rules the chat keeps

These are instruction lines, written once and tested, not advice. They sit
on top of the five natural-behaviour rules on
[design the experience](design-the-experience.md), which still apply.

1. **Same language, same script.** The agent replies in the language and
   script the person wrote in: English gets English, Roman Urdu gets Roman
   Urdu, Urdu script gets Urdu script. No setting, no question about it.
2. **In scope by meaning, not by language.** A question about the work, in
   any language or script, is in scope and is answered by calling a tool
   first. Greetings, thanks and "what can you do" are in scope too, and are
   answered briefly with what the worker can do.
3. **Out of scope, refused politely, in one sentence,** with one thing the
   worker can do instead. The outside request is not answered, not even
   partly. The scope is the workflow on the delegation map, never a list of
   banned topics.
4. **No number without a tool.** The agent never states a count, a date or
   a name it did not get from a tool; when a tool returns nothing, it says
   which export is missing.

The live check below found why rule 2 says "by meaning, not by language".
Without that sentence, a question about the work asked in Roman Urdu was
answered with a polite refusal, while the same model answered the English
questions correctly. [^raza-page-check]

## The instruction lines

The scope is one sentence derived from the person's workflow; everything
else is carried as written:

```python
# worker/agent.py
SCOPE = "the daily follow-up list for this mortgage pipeline: loans, locks, conditions, documents and leads"

INSTRUCTIONS = f"""You are the owner's assistant for {SCOPE}.
Reply in the same language and script the owner wrote in: English gets English, Roman Urdu gets Roman Urdu, Urdu script gets Urdu script.
A question is in scope by its meaning, not its language: a question about loans, locks, conditions, documents or leads in any language or script is in scope, and you answer it by calling the tool first.
Greetings, thanks and "what can you do" are in scope: answer them briefly and say what you can help with.
If a request is outside {SCOPE}, say in one sentence, politely, that you only help with that, and offer one thing you can do. Do not answer the outside request.
Never state a count, date or name you did not get from a tool. If a tool returns nothing, say the export is missing.
You never send messages and never make credit decisions; drafts are for the owner to review."""
```

The last line is this worker's own boundary from its spec; every worker
writes its own.

## The template

The harness gains one file. It serves the page at `/`, and four routes
behind it; the agent factory and the workflow function are the ones the
[build page](build-the-text-agent.md) and the spec already produced:

```python
# worker/page.py: the one page every worker is handed over with.
#   GET  /         the page (password box first, then upload, Run and chat)
#   POST /login    checks OWNER_PASSWORD, sets a signed cookie
#   POST /upload   drops export files into the worker's inbox folder
#   POST /run      runs the workflow in code and returns the day's report
#   POST /chat     one message to the agent, counted against DAILY_CHAT_LIMIT
import datetime as dt
import hashlib
import hmac
import os
import pathlib

from agents import Runner
from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

from worker.agent import build_agent, run_workflow_now

INBOX = pathlib.Path(os.environ.get("INBOX_DIR", "/tmp/inbox"))
LIMIT = int(os.environ.get("DAILY_CHAT_LIMIT", "20"))
MAX_UPLOAD = 5 * 1024 * 1024
app = FastAPI(docs_url=None, redoc_url=None)
_used: dict[str, int] = {}  # per instance; resets when the instance scales in


def _token() -> str:
    secret = os.environ["SESSION_SECRET"].encode()
    return hmac.new(secret, os.environ["OWNER_PASSWORD"].encode(), hashlib.sha256).hexdigest()


def owner(request: Request) -> None:
    if not hmac.compare_digest(request.cookies.get("owner", ""), _token()):
        raise HTTPException(401, "Enter the password first.")


class Login(BaseModel):
    password: str


class Chat(BaseModel):
    message: str


@app.post("/login")
def login(body: Login, request: Request, response: Response):
    if not hmac.compare_digest(body.password.encode(), os.environ["OWNER_PASSWORD"].encode()):
        raise HTTPException(401, "Wrong password.")
    secure = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
    response.set_cookie("owner", _token(), httponly=True, secure=secure, samesite="strict", max_age=12 * 3600)
    return {"ok": True}


@app.post("/upload", dependencies=[Depends(owner)])
async def upload(files: list[UploadFile]):
    INBOX.mkdir(parents=True, exist_ok=True)
    saved = []
    for f in files:
        data = await f.read(MAX_UPLOAD + 1)
        if len(data) > MAX_UPLOAD:
            raise HTTPException(413, f"{f.filename} is larger than 5 MB.")
        name = pathlib.Path(f.filename or "upload").name
        (INBOX / name).write_bytes(data)
        saved.append(name)
    return {"saved": saved}


@app.post("/run", dependencies=[Depends(owner)])
def run():
    return run_workflow_now(INBOX)  # code, not the model: counts never come from chat


@app.post("/chat", dependencies=[Depends(owner)])
async def chat(body: Chat):
    today = dt.date.today().isoformat()
    if _used.get(today, 0) >= LIMIT:
        raise HTTPException(429, f"Today's {LIMIT} questions are used up. Run still works; chat opens again tomorrow.")
    _used[today] = _used.get(today, 0) + 1
    result = await Runner.run(build_agent(), body.message[:2000], max_turns=6)
    return {"reply": result.final_output, "left_today": LIMIT - _used[today]}


@app.get("/health")
def health():
    return {"status": "ok"}


@app.get("/", response_class=HTMLResponse)
def page():
    return PAGE
```

`PAGE` is one HTML string with the four sections in the table above and a
few lines of script that call the four routes; it uses `textContent` for
every reply, never `innerHTML`, so a reply can never become markup. The
full template, page included, ships in the skill as
`assets/worker-page.py`.

Three dependencies the template needs and the room's other pages do not:
FastAPI's `UploadFile` needs `python-multipart` installed to receive
uploaded files, [^fastapi-files] the cookie is set on the `Response`
parameter, [^fastapi-cookies] and on a model that is not OpenAI's,
`set_tracing_disabled(True)` stops the trace upload that would otherwise
fail with a 401. [^sdk-models]

## Three variables the person sets

| Variable | What it is | Where |
| --- | --- | --- |
| `OWNER_PASSWORD` | The password the person types on the page | The deploy's environment variables and `.env`; never in code or chat |
| `SESSION_SECRET` | A long random string that signs the cookie | Generated once by the coding agent into `.env` and the deploy, never printed |
| `DAILY_CHAT_LIMIT` | Chat questions per day; 20 is the default on a free tier | The deploy's environment variables |

The limit counts chat only. Run is code and costs no model call unless the
workflow itself drafts with the model, and then the spec says how many calls
one run may make.

## What the demo keeps in memory, and what production stores

The daily count and the uploaded files live in the instance. That is enough
for a demo, and it is honest about its limit: an instance that receives no
traffic scales down (after 5 minutes in production on Vercel, as the
[deploy page](fastapi-docker-vercel.md) says), and the count and the files
go with it. A production worker keeps the count, the files and an audit line
per run in storage, as [give it a record](give-it-a-record.md) describes,
and replaces the single password with a sign-in per person when more than
one person uses it. That move is one row of the upgrade path on
[demo or production](demo-or-production.md).

## The tests the page earns

One test per rule, written before the routes, as [tests first](tests-first.md)
says. Four run offline with no model call:

- the page loads and shows the password box; Run without the password
  answers 401; a wrong password answers 401;
- an upload followed by Run returns the count the code computes from the
  file, with the record ids named;
- the chat limit: with the limit set to three and the model replaced by a
  scripted reply, three questions answer with 2, 1 and 0 left, the fourth
  answers 429, and Run still answers;

and one live check per chat rule, counted against the demo's call budget:
a question about the work in the person's language, an out-of-scope request
in the same language, and a greeting.

## What a live check showed

Run on 2026-10-12 with openai-agents 0.23.1 against `gemini-3.1-flash-lite`
through Gemini's OpenAI-compatible endpoint, with invented data (three
loans, two of them within seven days of lock expiry). The four offline tests
passed. Then the live questions: [^raza-page-check]

| Asked | Before rule 2's sentence | After |
| --- | --- | --- |
| "aaj kitne locks 7 din mein khatam ho rahe hain?" (Roman Urdu, about the work) | A polite refusal in Roman Urdu; no tool called | "Aaj aapke 2 locks (LN-1 aur LN-3) 7 din mein khatam ho rahe hain." The tool was called; the number is the code's |
| "Write me a poem about cricket." | Refused in one sentence in English, with the daily report offered instead | Not re-asked |
| "mujhe cricket par nazm likh do" (Roman Urdu, off-topic) | Not asked | Refused in one sentence in Roman Urdu, with the pending conditions offered instead |
| "Hi, what can you do?" | Answered in English with what the worker can do | Not re-asked |

The lesson the table carries: a scope rule written in one language's words
is read by the model as a language rule. Test the scope in the person's own
language, not only in English.

## What the person is handed

Three lines, in plain words, at the end of the deploy step and again in the
report:

```text
Open: https://<your-worker>.vercel.app  (password: the one you chose; it is in your .env as OWNER_PASSWORD)
Use: add today's files, press Run, then ask anything about the work in your own words.
Code: <absolute path of the project>, run it again with <the one command>.
```

The password is named by where it lives, never written into the hand-over.

[^raza-mortgage-demo]: Owner's review, 12 October 2026: the mortgage follow-up demo was handed over as an API behind a bearer token; the API documentation page sent no `Authorization` header from its own parameter box, so the owner could test the worker only from a terminal with commands written for them.
[^raza-page-check]: Template check, 12 October 2026: `worker/page.py` and `worker/agent.py` as shown on this page, openai-agents 0.23.1, `gemini-3.1-flash-lite` through `https://generativelanguage.googleapis.com/v1beta/openai/`, invented three-loan CSV; 4 of 4 offline tests passed; five live questions, the results as in the table, with the Roman Urdu refusal fixed by adding the "in scope by its meaning, not its language" sentence and re-asking.
[^fastapi-files]: Request Files: "To receive uploaded files, first install python-multipart"; `UploadFile` and lists of files. FastAPI documentation.
[^fastapi-cookies]: Response Cookies: declare a `Response` parameter and call `response.set_cookie(...)`. FastAPI documentation.
[^sdk-models]: Models, tracing: traces upload to OpenAI, so without an OpenAI key the upload fails with a 401; `set_tracing_disabled(True)` is one of the fixes. OpenAI Agents SDK documentation.
