Raza Rizwan

The worker's page: chat, upload and Run behind one password

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
Trust
human-reviewedHuman: Raza Rizwan ·
7 min read
Owner
Human: Raza Rizwan
Approved
Human: Raza Rizwan ·

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. 1 So every worker built from this record, at demo or production depth, 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 pageWhat it doesWho decides
PasswordOne box; nothing below it works until it is rightThe owner chooses it; it lives only in the deploy's environment variables
1. Add today's filesUploads the day's exports or documents to the workerThe person
2. RunRuns the workflow in code and shows the day's reportCode, never the model: every count, date and ranking is computed
3. Ask in your own wordsA chat box: the person asks anything about the work, in any language, and the agent answersThe 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, 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. 2

The instruction lines

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

# 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 and the spec already produced:

# 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, 3 the cookie is set on the Response parameter, 4 and on a model that is not OpenAI's, set_tracing_disabled(True) stops the trace upload that would otherwise fail with a 401. 5

Three variables the person sets

VariableWhat it isWhere
OWNER_PASSWORDThe password the person types on the pageThe deploy's environment variables and .env; never in code or chat
SESSION_SECRETA long random string that signs the cookieGenerated once by the coding agent into .env and the deploy, never printed
DAILY_CHAT_LIMITChat questions per day; 20 is the default on a free tierThe 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 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 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.

The tests the page earns

One test per rule, written before the routes, as tests first 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: 2

AskedBefore rule 2's sentenceAfter
"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 insteadNot re-asked
"mujhe cricket par nazm likh do" (Roman Urdu, off-topic)Not askedRefused 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 doNot 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:

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.

Footnotes

  1. 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. ↩

  2. 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. ↩ ↩2

  3. Request Files: "To receive uploaded files, first install python-multipart"; UploadFile and lists of files. FastAPI documentation. ↩

  4. Response Cookies: declare a Response parameter and call response.set_cookie(...). FastAPI documentation. ↩

  5. 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. ↩

On this page