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 ·
- 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 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, which still apply.
- 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.
- 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.
- 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.
- 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 PAGEPAGE 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
| 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 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
| 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:
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
-
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
Authorizationheader from its own parameter box, so the owner could test the worker only from a terminal with commands written for them. ↩ -
Template check, 12 October 2026:
worker/page.pyandworker/agent.pyas shown on this page, openai-agents 0.23.1,gemini-3.1-flash-litethroughhttps://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 -
Request Files: "To receive uploaded files, first install python-multipart";
UploadFileand lists of files. FastAPI documentation. ↩ -
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. ↩
Sources
- [^raza-mortgage-demo]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)
- [^raza-page-check]Template check of the worker's page, 12 October 2026: four offline tests and five live questions on gemini-3.1-flash-lite (attestation)
- [^fastapi-files]Request Files: UploadFile, and python-multipart required to receive uploaded files. FastAPI documentation
- [^fastapi-cookies]Response Cookies: setting a cookie on the Response parameter. FastAPI documentation
- [^sdk-models]Models: OpenAI-compatible endpoints and tracing without an OpenAI key. OpenAI Agents SDK documentation
Demo or production: the opening round, two depths, one upgrade path
The first thing this record settles with the person before any work is read: a demo (the default) or a production worker, which model provider and whether it is free or paid, and every other blank proposed in one pre-filled round; what each depth builds at each of the ten steps, what a demo leaves out on purpose, and how a demo becomes a production worker without starting again.
Answers
Next Page