twin is my personal digital twin: a small FastAPI app that lets visitors to my website chat with an AI that knows my background, skills, and experience, and answers as me. It's powered entirely by the OpenAI API (gpt-6-luna through the Responses API), streamed token-by-token to a chat UI I built from scratch, with no framework in between.
I wanted a chat widget on my personal site that could answer "so what do you actually do?" without me being online, and that stays on topic instead of turning into a general-purpose chatbot. The core idea, and the original code this project is built on, come from Ed Donner's agents course, specifically the 1_foundations/twin example. Big credit to Ed Donner for the original concept, prompt structure, and tool-calling pattern.
I took that starting point and rebuilt the serving layer, the frontend, and the guardrails around it to get it ready for a public-facing personal site, rather than a local demo. See What I changed below for the full list.
No setup needed, it's live: twin-828z.onrender.com
It's hosted on Render's free tier, so if it hasn't had a visitor in a while, the first message might take a bit to get a reply while the server spins back up. Worth the wait!
You need uv. The project runs on Python 3.14 by default (any version from 3.12 works), and uv downloads it for you if it is missing.
- Put your own
linkedin.pdf(on your LinkedIn profile: More, then Save to PDF),summary.txt, and optionallyfun_facts.txtin the project root. All three are git-ignored. - Copy
.env.exampleto.envand fill in your keys. - Start it:
uv run app.pyThe app opens at http://127.0.0.1:7860.
| Variable | Needed for |
|---|---|
OPENAI_API_KEY |
The twin's replies |
PUSHOVER_USER, PUSHOVER_TOKEN |
Push notifications when a visitor gets in touch or asks something the twin can't answer |
REASONING_EFFORT |
Optional, low by default. none replies a little faster and cheaper |
RATE_LIMIT_PER_MINUTE, RATE_LIMIT_PER_DAY, GLOBAL_LIMIT_PER_DAY, MAX_MESSAGE_CHARS, MAX_HISTORY_MESSAGES, MAX_TOOL_LOOPS, MAX_REPLY_TOKENS |
Optional, the cost and abuse guardrails (see app.py for the defaults) |
Render picks up Python 3.14 from .python-version (unless a PYTHON_VERSION variable is set in the dashboard, which takes priority). The build command can stay as pip install -r requirements.txt: that file is exported from uv.lock, so after changing dependencies, refresh both with:
uv lock --upgrade
uv export --no-hashes --no-dev --no-emit-project --format requirements-txt -o requirements.txtUpload linkedin.pdf, summary.txt, and fun_facts.txt as Secret Files, and set the variables above in the dashboard.
- Streamed, tool calls and all. Replies stream token-by-token from the OpenAI Responses API over a raw
StreamingResponse, including through tool-call loops. Text deltas go straight to the browser, and once a response completes, any function calls in it are run and handed back with their results for the next round. - A current, cheap model that still reasons. The twin runs on
gpt-6-luna, OpenAI's efficiency model, at $0.10 per million input tokens and $0.50 per million output tokens. It reasons atloweffort, which keeps the tool calls accurate (answer what's on record, only record questions that genuinely aren't) and on simple questions often uses no reasoning tokens at all. GPT-6 models only call tools while reasoning through the Responses API, which is why the twin uses Responses rather than Chat Completions. - The system prompt is cached. Every request starts with the same instructions and tools, sent with a shared
prompt_cache_key, so after the first message almost all of the roughly 1,800 input tokens are billed at the cached rate of $0.01 per million. A typical reply costs a few hundredths of a cent. - Visitors' chats aren't stored by OpenAI. Requests go out with
store=False. The model's reasoning comes back encrypted instead, so it can still be passed along with tool results within a reply. - Built for a public, unauthenticated endpoint. Since anyone can hit
/api/chatwith no login, the app enforces per-IP-per-minute, per-IP-per-day, and site-wide daily rate limits, plus caps on message length, history length, and tool-call loops, all tunable via environment variables without a redeploy. - The visitor's history is never trusted. The client sends its own chat history with every request, so the backend re-validates its shape, strips anything that isn't a plain user or assistant text turn, and truncates both message length and history length before it ever reaches the model.
- Never leaks the represented person's own email. Every email address found in the background data (LinkedIn export, summary, fun facts) is extracted at startup, and the
record_user_detailstool refuses to record any of them as if it were a visitor's, so the model can't accidentally "notify" the owner about themselves. - Getting in touch doesn't require an email. A visitor can express interest without leaving contact details. The tool still fires, just without an email, so no genuine interest goes unrecorded.
- Personal data lives in files, not code. The LinkedIn export, summary, and fun facts are separate, git-ignored files, loaded at runtime. In production, they're read from Render's Secret Files mount instead, so no personal data ever touches the git history. The LinkedIn text is tidied on load: non-breaking spaces are normalised and the "Page 1 of 2" footer is stripped, since it can land in the middle of an entry and split a school from its dates.
- A custom frontend, not a demo UI. The original project's Gradio
ChatInterfaceis swapped for a hand-built HTML/CSS/JS chat panel: streamed markdown rendering sanitised with DOMPurify, an auto-resizing input, cycling placeholder prompts, and clickable suggestion and "fun fact" chips. - One consistent voice. The system prompt tells the twin never to use em dashes and to use the Oxford comma in lists, so replies read the way I'd actually write, not the way a generic LLM defaults to.
Starting from Ed Donner's 1_foundations/twin, here's what's different in this version:
- Streaming, not blocking. Swapped the synchronous
OpenAIclient and single blockingchat()call for anAsyncOpenAIclient that streams tokens through FastAPI'sStreamingResponse, running the tool-call loop between streamed responses. - The Responses API and a current model. Moved from Chat Completions to the Responses API, and from
gpt-5.4-minitogpt-6-lunawith low reasoning effort, prompt caching, andstore=False.gpt-6-lunacosts $0.10/$0.50 per million tokens againstgpt-5.4-mini's $0.75/$4.50. - A custom frontend, not Gradio. Replaced
gr.ChatInterfaceentirely with a hand-built FastAPI static site (static/index.html,styles.css,script.js): a typing indicator, sanitised streamed markdown rendering, an auto-resizing textarea, cycling placeholder text, and clickable suggestion/fun-fact chips. - Rate limiting and abuse guardrails. The original had none, being a local Gradio demo. This version adds per-IP and global request limits, message and history size caps, and a tool-loop cap, all env-configurable, since this runs as a public unauthenticated endpoint.
- Visitor history is sanitised server-side. The original trusted Gradio's own history format completely. This version re-validates every incoming history item's shape and truncates it before it reaches the model.
- The twin can't leak its own owner's email. Added
OWNER_EMAILS, extracted from the background data at startup, and a check inrecord_user_detailsthat refuses to record any of those addresses as a visitor's. - Getting in touch no longer requires an email. The original's
record_user_detailsrequired one. This version records interest either way, so a visitor who doesn't want to share an email still gets noted. - Personal data files support a production secrets mount. Added a fallback to Render's
/etc/secrets/path, so the same code works locally (files in the project root) and in production (files uploaded as secrets), with a clear error if a file is missing from both. - Added
fun_facts.txtas an optional, separate data source for lighter trivia questions, feeding its own section of the system prompt. - Consistent voice. The system prompt now explicitly forbids em dashes and calls for the Oxford comma, so the twin's writing style matches how I actually write.
- Its own project. A standalone
pyproject.tomlanduv.lockon Python 3.14, souv run app.pyworks without the course's shared environment.
- Rate limiting is in-memory, so it resets on restart and isn't shared across multiple instances. That's a deliberate trade-off for a low-traffic personal site, not a security boundary.
- The twin only knows what's in
summary.txt,linkedin.pdf, andfun_facts.txt. Anything outside those files gets recorded as an unknown question rather than guessed at. MAX_REPLY_TOKENScan cut a long answer off mid-stream if set too low. It counts reasoning tokens as well as the reply, so the default of1500leaves plenty of room for typical chat-length replies.
Built on top of Ed Donner's agents course, specifically the 1_foundations/twin example, which supplied the original digital-twin concept, system prompt structure, and OpenAI function-calling pattern this project extends. This project uses the plain OpenAI API (the Responses API with function calling) directly, not the separate OpenAI Agents SDK.
Source for this project: github.com/havl-code/twin
Released under the MIT Licence, see LICENSE.
