Background
My friends and family know the best way to contact me is a good old-fashioned phone call. The convenience of just a quick conversation rather than trading messages means that I sometimes will neglect to reply to a text message from a friend. Of course I love my friends and would love to keep in better touch with them, but sometimes a few messages slip through the gaps. That's when I decided to start working on a fine-tuned LLM which speaks and texts like me.
Getting Text Messages
I am on iPhone and part of the iMessage ecosystem – on macOS these text messages are stored in what is essentially a SQLite database on the system, in the chat.db file. By querying this database, I was able to extract all the text messages I have sent in my entire life.
During this step I also pulled the contact information for each chat so that I could later classify messages by people's relationship to me.
Data Cleaning
This was a very important step. Even though I had access to every text message I ever sent, many of these messages are unusable. For example, we have text messages which are just authorization codes, or appointment confirmations of me just sending "Y".
Also, when I moved over from Android to iOS, I incorrectly imported my old text messages and actually ended up duplicating ALL my text messages twice over. These duplicate messages had to be filtered out.
Continuing on, another set of messages I could not use was all the group chat messages. I wanted this AI bot to be trained on conversations with just two participants: myself and the person I am texting. This is why, when I fine-tune the LLM, my text messages are the "assistant" messages and the "user" messages are from my friends. A group chat breaks this convention, as people can often have conversations that are not directly with you, or that are with multiple members of the group, so dropping these messages was important.
Reactions were another problem. When someone "likes" or "loves" a message, iMessage stores that as its own message in the database. I definitely did not want the model learning to reply to "want to grab lunch?" with 'Loved "want to grab lunch?"'. Luckily, chat.db marks reactions with a special message type (a non-zero associated_message_type, while regular texts are 0), so I filtered them out right in the SQL query before they ever made it into the dataset.
Once I had clean 1:1 messages, I used an LLM to do the parts that would have been painful to hard-code: splitting each contact's message history into individual conversations, classifying my relationship with each contact (partner, close friend, family, colleague, or other), and formatting everything into training pairs. Each of these steps is an API call to AWS Bedrock (with Claude Opus as the model behind it). The relationship label gets attached to every training example. The idea here is that I do not text my coworkers the way I text my close friends, and the model should know the difference.
The last step before the dataset left my Mac for the GPU box was PII redaction. A cleaning script scrubbed out emails, street addresses, and anything that looked like an SSN or credit card number – 218 redactions in total. After all the filtering, I ended up with 9,213 training examples.
Here's the whole thing end to end:
- chat.db
- Extract1:1 chats only, no reactions, dedupe old Android imports
- SegmentLLM splits each history into conversations
- ClassifyLLM labels each contact: partner / close friend / family / colleague / other
- FormatLLM turns conversations into training pairs
- Cleandrop empty turns, redact PII
- messages.jsonl9,213 examples
- QLoRA fine-tuneUnsloth + TRL on Gemma E4B
- LoRA adapter
- Merge adapterinto base model
- Convert to GGUFquantize to 4-bit (~2.5GB)
- scp
- ollama create
- Chat CLIwith /context switching
Training
With the dataset ready, it was finally time to train. I went with Gemma, Google's open model family – specifically the E4B variant, which is small enough to run on my Mac after training.
Actually training it on my Mac was not going to happen, though. Running a model just means pushing text through it, but training also means pushing corrections back through it – so on top of the model weights you are holding gradients, optimizer state, and activations in memory all at once, which multiplies the memory requirement several times over. On top of that, the whole modern fine-tuning stack (Unsloth and the 4-bit tricks it depends on) is built on NVIDIA's CUDA, which Macs simply do not have. So instead I rented a g5.24xlarge on AWS, which comes with NVIDIA A10G GPUs – actual CUDA hardware with tensor cores built to chew through exactly this kind of math. What would have been a multi-day science experiment on my Mac (assuming the software even cooperated) was a few hours on the GPU box.
The technique I used is called QLoRA. Instead of updating every weight in the model (very expensive), the base model stays frozen in 4-bit precision and you only train a small set of adapter layers on top. I used Unsloth with Hugging Face's TRL library, which made this surprisingly painless. Every training example gets wrapped in a system prompt that tells the model who it is texting – this is where those relationship labels from the pipeline pay off. The model does not just learn to text like me, it learns to text like me to my mom versus to my coworkers.
The output of all this is not a whole new model. It is a small LoRA adapter that gets merged back into the base model.
Running Locally
The whole point was to have this run on my own machine, so the last leg was getting the model from the GPU box onto my Mac. On the GPU side I merged the adapter into the base model and converted it to GGUF, a format built for running models on regular consumer hardware.
Then comes quantization. A model is really just billions of numbers (weights), normally stored at 16-bit precision. Quantizing rounds each of those numbers down to a lower precision – in my case 4-bit, so roughly a quarter of the size. You lose a little precision on every weight, but it turns out models are surprisingly tolerant of this – the responses stay basically the same, and in exchange the whole model shrinks from ~8GB to about 2.5GB. That is the difference between a model that fights my Mac for memory and one that runs comfortably alongside Chrome. Running quantized just means running that rounded-down copy: same model, same personality, slightly blurrier math.
From there it was a quick scp to my Mac and registering it with Ollama using a Modelfile. I also wrote a small chat CLI on top of Ollama with a /context command, so mid-conversation I can switch who the model thinks it is texting – partner, close friend, family, colleague – and watch the tone change.
Testing It
So... does it text like me? Honestly, kind of!
The style is definitely there. Message length, punctuation (or lack of it), slang, how I use emojis – it reads like me. Switching the relationship context works too: it is noticeably more polished texting a "colleague" than a "close friend".
The substance, on the other hand, is comically bad. It confidently makes plans that don't exist, references people I have never met, and commits to inside jokes that neither of us has ever made. Which makes sense – the model learned how I talk, not what I know. It has my voice and absolutely none of my memories.
It works well for generic questions and answers:
them> /context other Switched to: other (conversation reset) them> Hello George, hope you have a good day you> Hello, I am doing well, thank you. I hope you are also doing well.
But in examples that depend on my personal knowledge or relationships, we start seeing the gaps in the agent's capabilities. For example, when asked for a restaurant recommendation, my AI bot recommends a restaurant I have neither heard of nor eaten at. To be honest, I'm not sure if it even exists.
them> Hey, I remeber you told me about a really good restaurant in the city, do you remember which one it was? you> It was called Eatalia It was pretty good It was in the city I think it was on Madison between 4 and 5
Again, there is no grounding for scheduling information, so the answer is just a guess:
them> When are you coming home you> I'm coming home tonight
Again, it does not have my memories, or context on items that are not in its prompt or in its training. At least the answer here is a safe one, stating that I don't know, which may be better than claiming it exists in some location.
them> Do you remember where you stored the beach umbrella? you> I don't know where I put it
Additionally, and most comically, whenever I get a call while I am driving or doing something, I reply with a quick message. This has made the AI very eager to proclaim that I am in the middle of an action, even for basic questions.
them> /context family Switched to: family (conversation reset) them> Hey what you up to you> I'm in the car I'm going to the store
Conclusion
The biggest takeaway is that the dataset is the real asset. 90% of the work in this project was the data pipeline and, in hindsight, even more time should have been spent on it to filter out messages that I do not want in the training set.
Additionally, grounding is hard. A fine-tune can nail how you talk, but a useful texting bot would also need context on what is actually going on in my life. This can be solved with integrations and MCP connections that pull in data from my calendar to add grounding to answers centered around when I'm free.
A stronger system prompt, with instructions about myself, what I commit to, and how I think about conversations and commitments, would also help the model. Since at launch I did not develop the system prompt beyond a single line about who the model is texting, the model had to make many assumptions about me and how to reply.
In future iterations, using an even more refined dataset with more examples, using a better model, building out data retrieval integrations, and tuning behavior with a robust system prompt would all improve performance.
Check out the code on GitHub!