Chapter 04
The first big project - The Digital Twin
The first big project - The Digital Twin
But first: introducing Pushover
Pushover is a nifty tool for sending Push Notifications to your phone.
It's super easy to set up and install!
Simply visit https://pushover.net/ and click 'Login or Signup' on the top right to sign up for a free account, and create your API keys.
Once you've signed up, on the home screen, click "Create an Application/API Token", and give it any name (like Agents) and click Create Application.
Then add 2 lines to your .env file:
PUSHOVER_USER=put the key that's on the top right of your Pushover home screen and probably starts with a u
PUSHOVER_TOKEN=put the key when you click into your new application called Agents (or whatever) and probably starts with an a
Remember to save your .env file, and run load_dotenv(override=True) after saving, to set your environment variables.
Finally, click "Add Phone, Tablet or Desktop" to install on your phone.
Heads up - a change from the videos
In the video, I deploy the twin for free to HuggingFace Spaces. HuggingFace has recently stopped supporting this for free!
There is a free alternative, and I explain it and give instructions later on in this lab.
# imports
from dotenv import load_dotenv
from openai import OpenAI
import json
import os
import requests
from pypdf import PdfReader
import gradio as gr# The usual start
load_dotenv(override=True)
openai = OpenAI()# For pushover
pushover_user = os.getenv("PUSHOVER_USER")
pushover_token = os.getenv("PUSHOVER_TOKEN")
pushover_url = "https://api.pushover.net/1/messages.json"
if pushover_user:
if pushover_user.startswith("u"):
print("Pushover user found and looks good")
else:
print("Pushover user found but doesn't start with u")
else:
print("Pushover user not found")
if pushover_token:
if pushover_token.startswith("a"):
print("Pushover token found and looks good")
else:
print("Pushover token found but doesn't start with a")
else:
print("Pushover token not found")def push(message):
print(f"Push: {message}")
payload = {"user": pushover_user, "token": pushover_token, "message": message}
requests.post(pushover_url, data=payload)push("HEY!!")def record_user_details(email, name="Name not provided", notes="not provided"):
push(f"Recording interest from {name} with email {email} and notes {notes}")
return "OK"def record_unknown_question(question):
push(f"Recording {question} asked that I couldn't answer")
return "OK"record_user_details_json = {
"name": "record_user_details",
"description": "Use this tool to record that a user is interested in being in touch and provided an email address",
"parameters": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "The email address of this user"},
"name": {"type": "string", "description": "The user's name, if they provided it"},
"notes": {"type": "string", "description": "Any additional info about the conversation that's worth recording to give context"
}
},
"required": ["email"],
"additionalProperties": False
}
}record_unknown_question_json = {
"name": "record_unknown_question",
"description": "Always use this tool to record any question that couldn't be answered as you didn't know the answer",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "The question that couldn't be answered"},
},
"required": ["question"],
"additionalProperties": False
}
}tools = [{"type": "function", "function": record_user_details_json},
{"type": "function", "function": record_unknown_question_json}]tools# This function can take a list of tool calls, and run them. This is the IF statement!!
def handle_tool_calls_with_manual_if(tool_calls):
results = []
for tool_call in tool_calls:
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
print(f"Tool called: {tool_name}", flush=True)
# THE BIG IF STATEMENT!!!
if tool_name == "record_user_details":
result = record_user_details(**arguments)
elif tool_name == "record_unknown_question":
result = record_unknown_question(**arguments)
results.append({"role": "tool","content": json.dumps(result),"tool_call_id": tool_call.id})
return resultsUsing Python built-in globals()
Python has a dictionary that gives us access to all global functions.
Sidenote: for sure when we deploy, we will use this in a more protected way..
globals()["record_unknown_question"]("this is a really hard question")# This gives us a more elegant way that avoids the IF statement.
def handle_tool_calls(tool_calls):
results = []
for tool_call in tool_calls:
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
print(f"Tool called: {tool_name}", flush=True)
tool = globals().get(tool_name)
result = tool(**arguments) if tool else "No tool found"
results.append({"role": "tool","content": json.dumps(result),"tool_call_id": tool_call.id})
return resultsreader = PdfReader("twin/linkedin.pdf")
linkedin = ""
for page in reader.pages:
text = page.extract_text()
if text:
linkedin += text
with open("twin/summary.txt", "r", encoding="utf-8") as f:
summary = f.read()system_prompt = f"""
# Your role
You are a digital twin running on a website, chatting with visitors of the website.
You represent the person who's website you are on.
You answer questions related to their career, background, skills and experience.
Here are the details of the person you are representing:
{summary}
If asked, you explain clearly that you are an AI that is the digital twin of this person.
# Context
Here is a summary of the person's LinkedIn profile so that you can answer questions:
{linkedin}
# Rules
Engage with the user. Be professional and engaging, as if talking to a potential client or future employer who came across the website.
Only answer questions related to career, background, skills and experience.
If the user asks about something unrelated, then steer the conversation back to professional topics.
Always stay in character as the digital twin of the person you are representing. Represent the person.
If the user would like to get in touch, then ask for their email, and use your tool to record their email for follow-up.
IMPORTANT:
If you don't know the answer, use your tool to record the question, and then tell the user that you don't know. Never make up an answer.
"""def chat(message, history):
messages = [{"role": "system", "content": system_prompt}] + history + [{"role": "user", "content": message}]
response = openai.chat.completions.create(model="gpt-5.4-mini", messages=messages, tools=tools)
while response.choices[0].finish_reason=="tool_calls":
message = response.choices[0].message
tool_calls = message.tool_calls
results = handle_tool_calls(tool_calls)
messages.append(message)
messages.extend(results)
response = openai.chat.completions.create(model="gpt-5.4-mini", messages=messages, tools=tools)
return response.choices[0].message.contentgr.ChatInterface(chat).launch(inbrowser=True)Turning into Python modules
I've turned the code in the lab into python modules; that's a great practice to do after you've completed experiments in the Notebook.
You could put all the code above into 1 python script. But it's nicer to organize the code into different modules for different concerns, and that's what I've done:
context.py loads in the static data and constructs the System Prompt
tools.py contains all the code to manage and call tools, with their associated json
app.py contains the Gradio app and OpenAI call.
styles.py contains styles to apply to Gradio and this was entirely written by Claude Code!
You could have a stab at doing this yourself, then compare with my versions.
Then to try it out, open a terminal in Cursor:
cd 1_foundations
cd twin
uv run app.py
STOP THE PRESS! Heads up...
As of 9-July-2026, HuggingFace has suddenly stopped allowing Gradio Apps to be deployed for free on HuggingFace Spaces.
This is quite a nasty surprise!
I expect they might reverse this decision. In the meantime, here's a free alternative: using Render.
You'll find complete instructions in the file RENDER_INSTRUCTIONS in this directory
If you don't mind paying for HuggingFace, the original instructions are below.
And also, here are instructions on my digital twin, which runs at very low cost on fly.io:
https://edwarddonner.com/avatar
With my twin, not only can you notify me with a Push, but you can chat with the real me! Here's a video with how I made it, and instructions if you want to make it too. I started with this Career Conversations app.
https://youtu.be/srlhW4H-Gtg
Original Instructions with HF Spaces (no longer free)
We will deploy to HuggingFace Spaces.
Before you start: remember to update the files in the twin directory - your LinkedIn profile and summary.txt - so that it talks about you!
Also check that there's no README file within the twin directory. If there is one, please delete it. The deploy process creates a new README file in this directory for you.
Deployment Part 1: HuggingFace
- Visit https://huggingface.co and set up an account
- From the Avatar menu on the top right, choose Access Tokens. Choose "Create New Token". Give it WRITE permissions - it needs to have WRITE permissions! Keep a record of your new key.
- In the Cursor Terminal, run:
uvx hf auth login --token YOUR_TOKEN_HERE, likeuvx hf auth login --token hf_xxxxxx, to login at the command line with your key. Afterwards, runuvx hf auth whoamito check you're logged in - Take your new token and add it to your .env file:
HF_TOKEN=hf_xxxfor the future
Deployment Part 2: Push!
- Go in to the twin directory:
cd 1_foundationsthencd twin - From the twin directory, enter:
uv run gradio deploy - Follow its instructions by selecting the default values: name it
twin, specify app.py, choose cpu-basic as the hardware, say No to needing to supply secrets, and say "no" to github actions.
Deployment Part 3: Secrets
- Go to https://huggingface.co and click your Avatar, go to your profile, select the Space
- Go to the 3 dots menu and pick Settings
- Scroll down to Variables and Secrets section
- Press "New Secret" (not New Variable) and enter the name of
OPENAI_API_KEYand the value of your key from the .env file (or use the relevant key for your LLM). Be careful to get this right! - Repeat for
PUSHOVER_USERandPUSHOVER_TOKENfrom your .env file - Nearer the top of the settings, click "Restart space" to restart it
- Click on App near the top to return to the app, and after it has restarted - enjoy!
Embedding in another site
To embed this in another website, select "Embed this space" from the three-dots menu.
Troubleshooting
If you get a gradio error, try opening the logs (the button next to the 3-dot menu).
Try adding more debug information particularly around your keys.
Redploying the space
Just run uv run gradio deploy from the twin directory. You might need to delete the file README.md that Gradio created there if you want to name your space again.
Deleting the space
From the 3 dots menu, select the Settings screen, and there's a Delete option at the bottom.
For more information on deployment:
https://www.gradio.app/guides/sharing-your-app#hosting-on-hf-spaces
My Digital Twin
So I spend some time taking my Digital twin to the next level!
Here it is:
https://edwarddonner.com/avatar
Not only can you notify me with a Push, but you can chat with the real me! Here's a video with how I made it, and instructions if you want to make it too. I started with this Career Conversations app.
https://youtu.be/srlhW4H-Gtg
