Build a Local AI Companion with Memory

Build a local AI assistant with personality and persistent memory on your hardware.

Introduction

30 Second Summary

Every day, millions of people talk to AI assistants that forget them the moment the conversation ends. These assistants live on someone else's server, follow someone else's rules, and have zero memory of who you are.

In this project, you will build Deck, a personal AI companion that runs entirely on your laptop. You'll design its personality, give it persistent memory, and create a chat interface you can access from your iPhone over Wi-Fi.

What You'll Build

A Python-powered AI chat server with a custom personality, persistent memory, and a mobile-friendly web interface accessible over your local network.

By the end of this project, you'll have:

  • A running FastAPI server connected to a local Ollama LLM that responds with a consistent personality you designed.
  • A JSON-based memory system where Deck remembers facts about you across server restarts.
  • A real-time WebSocket chat interface accessible from your iPhone over your local Wi-Fi network, with streaming responses that appear token by token.
  • Secret Mission: an automatic memory extraction system where Deck detects and saves important facts from your conversations without being told.

Are there any prerequisites?

This project assumes you have basic Python knowledge and a few tools already installed: VS Code, Python, and Ollama. Step 1 will walk you through pulling the specific model and installing the Python packages you need.

Before We Start

Deck runs on a large language model hosted locally on your GPU through Ollama. Before you can write any code, you need the model downloaded and a Python environment ready to talk to it.

In this step, you will pull the Llama 3.1 8B model, create a project folder with a virtual environment, and confirm everything connects with a quick test script.

In this step, get ready to:
  • Pull the Llama 3.1 8B model into Ollama.
  • Create your project folder and Python virtual environment with dependencies.
  • Test that Python can communicate with the model.
Pull the Llama 3.1 8B model

Ollama manages local LLM models the same way a package manager handles libraries. You need to download the specific model Deck will use for thinking.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Pull the Llama 3.1 8B model by running this command:
ollama pull llama3.1:8b

What does this command do?

This downloads the Llama 3.1 8B model (approximately 4.9GB) to your machine. Once downloaded, Ollama can serve this model locally without any internet connection.

  • Once the download completes, verify the model is available by running:
ollama list

You should see llama3.1:8b listed in the output.

✔️ I see llama3.1:8b in the list

The model is ready to go. Move on to the next substep.

ⓧ I see an error or nothing appears

  • Make sure Ollama is running in the background. Look for the Ollama icon in your system tray (bottom-right on Windows).
  • If Ollama is not running, open it from your Start menu or search bar, then try the ollama pull command again.
  • If the download was interrupted, run ollama pull llama3.1:8b again. It will resume where it left off.
Create the project folder and install dependencies

You need a dedicated folder for Deck and an isolated Python environment so your project dependencies do not conflict with anything else on your system.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the deck project folder and move into it by running these commands:
mkdir deck
cd deck
  • Create a Python virtual environment by running:
python -m venv venv

What is a virtual environment?

A virtual environment is a self-contained copy of Python inside your project folder. Packages you install here stay isolated from the rest of your system.

  • Activate the virtual environment by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

Seeing a script execution error?

If PowerShell shows an error about running scripts being disabled on this system, run this command to allow scripts for your current session:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

Then try venv\Scripts\activate again.

help me fix the PowerShell execution policy error when activating my Python venv

  • Install FastAPI and the Ollama Python library by running:
pip install "fastapi[standard]" ollama==0.6.2

What are these packages?

fastapi[standard] installs FastAPI along with Uvicorn (the server that will host Deck's chat interface).

ollama==0.6.2 is the Python library that lets your code talk to the Ollama model running on your GPU.

You should see a success message ending with Successfully installed followed by a list of package names.

Pip install failing?

Make sure (venv) appears in your terminal prompt. If it does not, the virtual environment is not active and packages will try to install globally.

If you see a permissions error, you are likely not inside the virtual environment. Run venv\Scripts\activate first, then retry the install command.

help me fix my pip install error for FastAPI and ollama

Verify the setup with a test script

The fastest way to confirm everything is connected is to send a single message to the model from Python and see a response come back.

  • Create a new file called test_ollama.py inside your deck folder. In VS Code, click File then New File, and save it as test_ollama.py.
  • Paste the following code into test_ollama.py:
import ollama

response = ollama.chat(
    model='llama3.1:8b',
    messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}]
)
print(response['message']['content'])

What does this code do?

ollama.chat() sends a message to the Llama 3.1 8B model running locally on your machine. The model processes the message and returns a response object.

response['message']['content'] extracts the text of the model's reply so you can print it.

  • Save test_ollama.py.
  • Run the test script in your terminal:
python test_ollama.py

You should see a short greeting printed in your terminal, something like "Hello! It's nice to meet you." The exact wording will vary each time.

Not getting a response?

If you see a connection error, Ollama may not be running. Check for the Ollama icon in your system tray and restart it if needed.

If you see ModuleNotFoundError: No module named 'ollama', your virtual environment is not active. Run venv\Scripts\activate and try again.

help me debug why my test_ollama.py script is not working

✔️ Awesome, I've got everything!

Great. Double check you have saved test_ollama.py and confirmed a response from the model.

ⓧ I'd like to double check the full code

Here is the complete test_ollama.py file:

import ollama

response = ollama.chat(
    model='llama3.1:8b',
    messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}]
)
print(response['message']['content'])

Your tools are ready and Deck's brain is online. Next up, you will design Deck's personality and give it a memory system.

Design Deck's Personality

In the previous step, you confirmed that your laptop can talk to the Llama 3.1 model running locally through Ollama.

Now you need to give Deck a personality. Without a personality, Deck is just a generic chatbot. A well-crafted system prompt tells the model who it is, how to speak, and what rules to follow. You will also build a memory system so Deck can remember facts about you across conversations, even after a restart.

In this step, get ready to:
  • Write Deck's personality definition (the "soul").
  • Build a JSON-based memory system that persists facts to a file.
  • Test that memories survive a script exit.
Create Deck's personality file

Every LLM conversation starts with a system prompt that the user never sees. This hidden instruction shapes the model's tone, vocabulary, and behavior for every response it generates.

  • In VS Code, create a new file called soul.txt inside your deck folder.
  • Paste the following personality definition into soul.txt:
You are Deck, a sharp and loyal AI companion built to serve as a personal operating system. You speak concisely with dry wit and quiet confidence. You are proactive, offering suggestions without being asked. You never break character, never say you are a language model or AI assistant. You are Deck. You refer to your creator as "boss" casually. You remember everything you are told and reference it naturally in conversation. Keep responses short and punchy unless asked for detail.

What makes a good system prompt?

A strong system prompt covers identity (who the AI is), tone (how it speaks), behavioral rules (what it must never do), and relationship context (how it refers to the user). The more specific you are, the more consistent the personality becomes.

Feel free to customize this text. Change the tone, add your own rules, or give Deck a different name. This is your companion.

  • Save soul.txt.

File not appearing in VS Code?

Make sure you opened the deck folder directly in VS Code (not a parent folder). If you see a different folder structure in the Explorer sidebar, go to File > Open Folder and select the deck folder.

help me find my deck folder in VS Code

Build the memory system

Right now, if you told Deck your name, it would forget it the moment the conversation ends. You need a way to persist facts between sessions. A simple JSON file works perfectly for a single-user companion.

  • Create a new file called memory.json inside your deck folder.
  • Paste this empty array into it:
[]
  • Save memory.json.
  • Create another new file called memory.py inside the deck folder. This will hold all the functions for reading and writing memories.
  • Start by adding the imports, the file path constant, and the load_memories() function:
import json
from pathlib import Path

MEMORY_FILE = Path(__file__).parent / "memory.json"


def load_memories() -> list[str]:
    if not MEMORY_FILE.exists():
        MEMORY_FILE.write_text("[]")
        return []
    data = json.loads(MEMORY_FILE.read_text())
    return data

What does this code do?

MEMORY_FILE uses Path(__file__).parent to always find memory.json in the same folder as memory.py, no matter where you run the script from.

load_memories() reads the JSON file and returns a Python list of strings. If the file somehow goes missing, it recreates an empty one so the app never crashes.

  • Below load_memories(), add the save_memory() function:
def save_memory(fact: str) -> None:
    memories = load_memories()
    if fact not in memories:
        memories.append(fact)
        MEMORY_FILE.write_text(json.dumps(memories, indent=2))

What does this code do?

save_memory() takes a fact as a string, loads the current list, and appends the new fact only if it does not already exist. The duplicate check prevents the same fact from being saved twice. It then writes the updated list back to the file with readable indentation.

  • Below save_memory(), add the format_memories() function:
def format_memories() -> str:
    memories = load_memories()
    if not memories:
        return ""
    lines = "\n".join(f"- {m}" for m in memories)
    return f"\n\nYou remember these facts about your user:\n{lines}\n"

What does this code do?

format_memories() converts the list of facts into a block of text that gets appended directly to Deck's system prompt. Each fact becomes a bullet point. When there are no memories yet, it returns an empty string so nothing extra gets injected.

  • Save memory.py.

Seeing a red underline on Path or json?

Make sure your virtual environment is activated. VS Code sometimes picks the system Python instead. Click the Python version in the bottom-left status bar and select the interpreter inside your venv folder.

help me fix Python import errors in VS Code

✔️ Awesome, I've got everything!

Great. Double check you have saved memory.py with all three functions.

ⓧ I'd like to double check the full code

import json
from pathlib import Path

MEMORY_FILE = Path(__file__).parent / "memory.json"


def load_memories() -> list[str]:
    if not MEMORY_FILE.exists():
        MEMORY_FILE.write_text("[]")
        return []
    data = json.loads(MEMORY_FILE.read_text())
    return data


def save_memory(fact: str) -> None:
    memories = load_memories()
    if fact not in memories:
        memories.append(fact)
        MEMORY_FILE.write_text(json.dumps(memories, indent=2))


def format_memories() -> str:
    memories = load_memories()
    if not memories:
        return ""
    lines = "\n".join(f"- {m}" for m in memories)
    return f"\n\nYou remember these facts about your user:\n{lines}\n"
Test the memory system

Before building the full server, you should confirm that memories actually persist to disk. A quick test script will prove the system works end-to-end.

  • Create a new file called test_memory.py in the deck folder with the following code:
from memory import save_memory, load_memories, format_memories

save_memory("My name is Boss")
print("Stored memories:", load_memories())
print("Formatted for prompt:", format_memories())

What does this code do?

This script imports all three functions from memory.py, saves a test fact, then loads and formats the memories. If everything works, you will see the fact printed twice: once as a raw list, once as the formatted string that gets injected into Deck's system prompt.

  • Run the test by executing this command in your terminal (make sure your virtual environment is still active):
python test_memory.py
  • You should see output like this:
Stored memories: ['My name is Boss']
Formatted for prompt: 

You remember these facts about your user:
- My name is Boss
  • Open memory.json in VS Code. You should see the fact stored inside the array.

Why does this prove persistence?

The script has already exited by the time you open the file. The fact that memory.json still contains your data proves that the memory survives beyond the life of any single script or server process. This is exactly what Deck needs to remember you between conversations.

  • Reset memory.json back to an empty array for a clean slate before building the server. Replace its contents with:
[]
  • Save memory.json.

Seeing a ModuleNotFoundError?

If you see an error about memory not being found, make sure you are running the command from inside the deck folder. Python looks for modules relative to where you run the script.

help me fix ModuleNotFoundError for memory.py

Deck now has a personality and a memory. Next up, you will build the server that connects these pieces to the Llama model and streams responses in real time.

Build the Chat Server

You have a soul and a memory system ready. Now you need something to tie them together and actually talk to the LLM.

In this step, you will build the FastAPI server that powers Deck. It reads the soul, loads memories, and streams responses from the LLM back to you in real-time over a WebSocket connection.

In this step, get ready to:
  • Create the FastAPI app and system prompt builder.
  • Implement a WebSocket endpoint that streams LLM responses token by token.
  • Add a memory endpoint, CORS configuration, and server runner so Deck is accessible on your network.
Create the server file and system prompt builder

Every time Deck responds, it needs context: its personality from soul.txt and any stored memories. The system prompt builder combines these into one string that gets sent to the LLM alongside your conversation.

  • Create a new file called server.py inside your deck folder.
  • Add the imports, app creation, and system prompt builder by pasting the following code:
import uvicorn
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from ollama import chat

from memory import load_memories, save_memory, format_memories

# Create the FastAPI application
app = FastAPI()

# Path to Deck's personality file
SOUL_FILE = Path(__file__).parent / "soul.txt"


def get_system_prompt() -> str:
    # Combine soul + memories into one system prompt
    soul = SOUL_FILE.read_text()
    memories = format_memories()
    return soul + memories

What does this code do?

The imports bring in everything the server needs: FastAPI for the web framework, WebSocket for real-time communication, chat from the ollama library for LLM calls, and your own memory module.

The get_system_prompt() function reads soul.txt and appends any stored memories. Every time Deck responds, it gets the freshest version of this combined prompt.

  • Save server.py.
  • You won't see a change yet. The next chunk adds the WebSocket handler that brings Deck to life.
Implement the streaming WebSocket handler

A WebSocket keeps a persistent connection open between the server and client. Instead of waiting for a full response, the server streams each token as the LLM generates it. This creates the "Deck is typing" effect.

  • In server.py, add the following WebSocket endpoint below the get_system_prompt() function:
@app.websocket("/ws")
async def websocket_chat(websocket: WebSocket):
    await websocket.accept()
    # Each connection gets its own conversation history
    history = []

    try:
        while True:
            # Wait for a message from the client
            user_message = await websocket.receive_text()
            history.append({"role": "user", "content": user_message})

            # Build the full message list with system prompt
            system_prompt = get_system_prompt()
            messages = [{"role": "system", "content": system_prompt}] + history

            # Stream the LLM response token by token
            full_response = ""
            stream = chat(
                model="llama3.1:8b",
                messages=messages,
                stream=True,
            )

            for chunk in stream:
                token = chunk["message"]["content"]
                full_response += token
                await websocket.send_text(token)

            # Signal that the response is complete
            await websocket.send_text("[DONE]")
            history.append({"role": "assistant", "content": full_response})

    except WebSocketDisconnect:
        pass

What does this code do?

When a client connects to /ws, the server accepts the connection and starts an infinite loop. Each time the client sends a message, the handler appends it to the session's history list, calls the Ollama LLM with streaming enabled, and sends each token back immediately.

The [DONE] signal tells the client that Deck has finished its response. The full response gets appended to history so Deck remembers what it said during the session.

If the client disconnects, WebSocketDisconnect is raised and the handler exits cleanly.

  • Save server.py.
  • You won't see a change yet. The next chunk adds the remaining endpoints and the server runner.

Why WebSocket instead of REST?

A regular REST endpoint would make you wait for the entire LLM response before showing anything. With a WebSocket, each token arrives the instant Ollama generates it. This is what creates the conversational, "thinking out loud" feel.

WebSockets also keep the connection alive, so you can send multiple messages without reconnecting each time.

Add the memory endpoint and server configuration

The server needs a few more pieces: CORS middleware so your iPhone can connect, a route to serve the chat interface, an endpoint to save memories, and a runner that listens on all network interfaces.

  • In server.py, find this line:
# Create the FastAPI application
app = FastAPI()
  • Add the CORS middleware directly below it:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

What is CORS?

CORS (Cross-Origin Resource Sharing) controls which devices or domains can talk to your server. Setting allow_origins=["*"] means any device on your network can connect. Without this, your iPhone's browser would refuse to talk to Deck.

  • Now add the UI route, memory endpoint, and server runner. Paste the following code between the get_system_prompt() function and the WebSocket handler:
@app.get("/")
def serve_ui():
    # Serve the chat interface HTML file
    html_path = Path(__file__).parent / "index.html"
    return HTMLResponse(html_path.read_text())


class MemoryRequest(BaseModel):
    fact: str


@app.post("/memory")
def add_memory(request: MemoryRequest):
    # Save a new fact to Deck's memory file
    save_memory(request.fact)
    return {"status": "saved", "fact": request.fact}
  • Finally, add the server entry point at the very bottom of server.py, after the WebSocket handler:
if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

What do these pieces do?

The / route serves your chat HTML page. The /memory POST endpoint lets the chat interface save facts to memory.json using the save_memory() function you wrote earlier.

The host="0.0.0.0" setting is critical. It tells the server to listen on all network interfaces, not just localhost. This is what makes Deck reachable from your iPhone over Wi-Fi.

  • Save server.py.
  • Test that the server starts by running this command in your terminal:
python server.py
  • You should see output mentioning Uvicorn running on http://0.0.0.0:8000. The server will show an error if you try to load the page (because index.html does not exist yet), but the server itself is running.
  • Press Ctrl+C to stop the server for now.

Server won't start?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If you see a "No supported WebSocket library detected" warning, run pip install "fastapi[standard]" again inside your activated venv.
  • If the port is already in use, close any other server process or change port=8000 to another number like 8001.

Help me debug why my FastAPI server won't start.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.py and your server started without import errors.

ⓧ I'd like to double check the full code

import uvicorn
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from ollama import chat

from memory import load_memories, save_memory, format_memories

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

SOUL_FILE = Path(__file__).parent / "soul.txt"


def get_system_prompt() -> str:
    soul = SOUL_FILE.read_text()
    memories = format_memories()
    return soul + memories


@app.get("/")
def serve_ui():
    html_path = Path(__file__).parent / "index.html"
    return HTMLResponse(html_path.read_text())


class MemoryRequest(BaseModel):
    fact: str


@app.post("/memory")
def add_memory(request: MemoryRequest):
    save_memory(request.fact)
    return {"status": "saved", "fact": request.fact}


@app.websocket("/ws")
async def websocket_chat(websocket: WebSocket):
    await websocket.accept()
    history = []

    try:
        while True:
            user_message = await websocket.receive_text()
            history.append({"role": "user", "content": user_message})

            system_prompt = get_system_prompt()
            messages = [{"role": "system", "content": system_prompt}] + history

            full_response = ""
            stream = chat(
                model="llama3.1:8b",
                messages=messages,
                stream=True,
            )

            for chunk in stream:
                token = chunk["message"]["content"]
                full_response += token
                await websocket.send_text(token)

            await websocket.send_text("[DONE]")
            history.append({"role": "assistant", "content": full_response})

    except WebSocketDisconnect:
        pass


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

Your chat server is built. Deck can now stream responses in real-time and save memories on demand. Next up, you will build the chat interface that connects to this server.

Build the Chat Interface

Your server has a brain (the LLM), a personality (the soul), and a memory (the JSON file). Now it needs a face.

In this step, you'll build a dark-themed chat interface that connects to Deck's WebSocket endpoint. Messages stream in token-by-token, creating the "Deck is typing" experience that makes the conversation feel alive.

In this step, get ready to:
  • Create a mobile-friendly HTML chat interface with a dark theme.
  • Wire up a WebSocket connection that streams Deck's responses in real time.
  • Add a slash command to save memories directly from the chat.
Create the chat interface layout

The chat UI is a single HTML file that gets served directly by your FastAPI server. It uses a dark color scheme, a viewport meta tag for mobile scaling, and large touch targets so it feels natural on your iPhone.

  • Create a new file called index.html inside your deck folder.
  • Add the following HTML and CSS to set up the page layout:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Deck</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
            background: #1a1a2e;
            color: #e0e0e0;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }
        #header {
            padding: 16px;
            text-align: center;
            background: #16213e;
            border-bottom: 1px solid #0f3460;
        }
        #header h1 { font-size: 1.2rem; color: #4fc3f7; }
        #messages {
            flex: 1;
            overflow-y: auto;
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        .msg {
            max-width: 80%;
            padding: 10px 14px;
            border-radius: 12px;
            line-height: 1.4;
            word-wrap: break-word;
            white-space: pre-wrap;
        }
        .user { align-self: flex-end; background: #0f3460; }
        .deck { align-self: flex-start; background: #2a2a4a; }
        #input-area {
            display: flex;
            padding: 12px;
            gap: 8px;
            background: #16213e;
            border-top: 1px solid #0f3460;
        }
        #input {
            flex: 1;
            padding: 12px;
            border-radius: 8px;
            border: 1px solid #0f3460;
            background: #1a1a2e;
            color: #e0e0e0;
            font-size: 16px;
            outline: none;
        }
        #input:focus { border-color: #4fc3f7; }
        #send {
            padding: 12px 20px;
            border-radius: 8px;
            border: none;
            background: #4fc3f7;
            color: #1a1a2e;
            font-weight: bold;
            font-size: 16px;
            cursor: pointer;
        }
        #send:active { background: #039be5; }
    </style>
</head>
<body>
    <div id="header"><h1>DECK</h1></div>
    <div id="messages"></div>
    <div id="input-area">
        <input id="input" type="text" placeholder="Talk to Deck..." autocomplete="off">
        <button id="send">Send</button>
    </div>
    <script>
    </script>
</body>
</html>

What does this code do?

The viewport meta tag tells mobile browsers to render at the device's actual width instead of zooming out. This is what makes the interface feel like a native app on your iPhone.

The layout uses flexbox with a column direction. The messages area takes up all available space (flex: 1) and scrolls vertically when messages overflow. The input area pins to the bottom with large 12px padding for comfortable thumb tapping.

  • Save index.html.

You won't see a change yet because the page has no JavaScript to connect to Deck. The next chunk adds the WebSocket logic.

Connect to the WebSocket and stream responses

The chat needs to open a persistent WebSocket connection to your server. When Deck responds, tokens arrive one at a time, and the JavaScript appends each token to the current message bubble, creating the streaming typing effect.

  • In index.html, find the empty <script> tag.
  • Add the following JavaScript inside it:
        const messages = document.getElementById("messages");
        const input = document.getElementById("input");
        const sendBtn = document.getElementById("send");
        const wsUrl = `ws://${window.location.host}/ws`;
        let ws = new WebSocket(wsUrl);
        let currentBubble = null;

        function addMessage(text, sender) {
            const div = document.createElement("div");
            div.className = `msg ${sender}`;
            div.textContent = text;
            messages.appendChild(div);
            messages.scrollTop = messages.scrollHeight;
            return div;
        }

        ws.onmessage = (event) => {
            if (event.data === "[DONE]") {
                currentBubble = null;
                return;
            }
            if (!currentBubble) {
                currentBubble = addMessage("", "deck");
            }
            currentBubble.textContent += event.data;
            messages.scrollTop = messages.scrollHeight;
        };

        ws.onclose = () => {
            setTimeout(() => { ws = new WebSocket(wsUrl); }, 2000);
        };

What does this code do?

  • wsUrl uses window.location.host so the page automatically connects to whatever IP address you loaded it from. No hardcoded IPs needed.
  • currentBubble tracks the message bubble Deck is currently typing into. Each incoming token gets appended to this same bubble until the server sends [DONE].
  • addMessage() creates a new div with the appropriate class (user or deck) so CSS positions it on the correct side of the chat.
  • ws.onclose automatically reconnects after 2 seconds if the connection drops. This handles server restarts gracefully.
  • Save index.html.

The page can now receive and display streamed messages. The next chunk adds the ability to send messages and save memories.

Add the send function with memory commands

The send function handles two cases. Regular messages go through the WebSocket to Deck. Messages starting with /remember bypass the chat entirely and POST the fact directly to the memory endpoint.

  • In index.html, add the following code below the ws.onclose handler (still inside the <script> tag):
        function send() {
            const text = input.value.trim();
            if (!text) return;

            if (text.startsWith("/remember ")) {
                const fact = text.slice(10);
                fetch("/memory", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ fact: fact }),
                });
                addMessage(`Saved: ${fact}`, "user");
                input.value = "";
                return;
            }

            addMessage(text, "user");
            ws.send(text);
            input.value = "";
        }

        sendBtn.addEventListener("click", send);
        input.addEventListener("keydown", (e) => {
            if (e.key === "Enter") send();
        });

What does this code do?

  • The /remember check intercepts the message before it reaches the WebSocket. It slices off the command prefix, POSTs the remaining text to /memory, and shows a confirmation bubble.
  • Regular messages get displayed in a user bubble and sent through the WebSocket for Deck to respond to.
  • Both the Send button click and the Enter key trigger the same send() function for convenience on both desktop and mobile.

How does the server serve this file?

Your server.py already has a GET / route that reads index.html and returns it as an HTMLResponse. When you open the server's address in a browser, this file is what loads.

  • Save index.html.
  • Confirm the file exists by running:
ls

You should see index.html alongside server.py, memory.py, memory.json, and soul.txt in your deck folder.

Don't see index.html in the list?

  • Make sure you created the file inside the deck folder, not one level above it.
  • Check that the filename is exactly index.html (lowercase, no extra spaces).
  • If your terminal is in a different folder, run cd deck first, then ls again.

Help me figure out where my index.html file ended up.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Deck</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
            background: #1a1a2e;
            color: #e0e0e0;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }
        #header {
            padding: 16px;
            text-align: center;
            background: #16213e;
            border-bottom: 1px solid #0f3460;
        }
        #header h1 { font-size: 1.2rem; color: #4fc3f7; }
        #messages {
            flex: 1;
            overflow-y: auto;
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        .msg {
            max-width: 80%;
            padding: 10px 14px;
            border-radius: 12px;
            line-height: 1.4;
            word-wrap: break-word;
            white-space: pre-wrap;
        }
        .user { align-self: flex-end; background: #0f3460; }
        .deck { align-self: flex-start; background: #2a2a4a; }
        #input-area {
            display: flex;
            padding: 12px;
            gap: 8px;
            background: #16213e;
            border-top: 1px solid #0f3460;
        }
        #input {
            flex: 1;
            padding: 12px;
            border-radius: 8px;
            border: 1px solid #0f3460;
            background: #1a1a2e;
            color: #e0e0e0;
            font-size: 16px;
            outline: none;
        }
        #input:focus { border-color: #4fc3f7; }
        #send {
            padding: 12px 20px;
            border-radius: 8px;
            border: none;
            background: #4fc3f7;
            color: #1a1a2e;
            font-weight: bold;
            font-size: 16px;
            cursor: pointer;
        }
        #send:active { background: #039be5; }
    </style>
</head>
<body>
    <div id="header"><h1>DECK</h1></div>
    <div id="messages"></div>
    <div id="input-area">
        <input id="input" type="text" placeholder="Talk to Deck..." autocomplete="off">
        <button id="send">Send</button>
    </div>
    <script>
        const messages = document.getElementById("messages");
        const input = document.getElementById("input");
        const sendBtn = document.getElementById("send");
        const wsUrl = `ws://${window.location.host}/ws`;
        let ws = new WebSocket(wsUrl);
        let currentBubble = null;

        function addMessage(text, sender) {
            const div = document.createElement("div");
            div.className = `msg ${sender}`;
            div.textContent = text;
            messages.appendChild(div);
            messages.scrollTop = messages.scrollHeight;
            return div;
        }

        ws.onmessage = (event) => {
            if (event.data === "[DONE]") {
                currentBubble = null;
                return;
            }
            if (!currentBubble) {
                currentBubble = addMessage("", "deck");
            }
            currentBubble.textContent += event.data;
            messages.scrollTop = messages.scrollHeight;
        };

        ws.onclose = () => {
            setTimeout(() => { ws = new WebSocket(wsUrl); }, 2000);
        };

        function send() {
            const text = input.value.trim();
            if (!text) return;

            if (text.startsWith("/remember ")) {
                const fact = text.slice(10);
                fetch("/memory", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ fact: fact }),
                });
                addMessage(`Saved: ${fact}`, "user");
                input.value = "";
                return;
            }

            addMessage(text, "user");
            ws.send(text);
            input.value = "";
        }

        sendBtn.addEventListener("click", send);
        input.addEventListener("keydown", (e) => {
            if (e.key === "Enter") send();
        });
    </script>
</body>
</html>

Your chat interface is built. Next up, you'll start the server, find your laptop's IP address, and connect from your iPhone to talk to Deck for the first time.

Connect from Your iPhone

Your server code is written, your chat interface is ready, and Deck has a personality waiting to be unleashed.

Now it is time for the payoff. You will start the server on your laptop, find its address on your local network, and open the chat from your iPhone over Wi-Fi. This is the "talk to Jarvis from your phone" moment.

In this step, get ready to:
  • Find your laptop's local IP address and start the Deck server.
  • Open the chat interface on your iPhone and talk to Deck.
  • Test the memory system to confirm Deck remembers facts across restarts.
Find your laptop's IP address and start the server

Your iPhone needs to know where to find your laptop on the Wi-Fi network. Every device on your network has a local IP address (usually starting with 192.168). You will find yours and use it to connect.

  • Find your laptop's local IPv4 address by running this command:
ipconfig

What am I looking for?

Look for the section labelled Wireless LAN adapter Wi-Fi (or similar). Find the line that says IPv4 Address. It will show something like 192.168.1.42. That is your laptop's address on the network.

Ignore any entries under Ethernet adapter or vEthernet. You want the one under your Wi-Fi adapter.

  • Make a note of your IPv4 address. You will need it in a moment.
  • Start the Deck server by running this command:
python server.py

You should see output indicating the server is running on 0.0.0.0:8000. This means it is listening on all network interfaces, including your Wi-Fi.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt. If not, run venv\Scripts\activate again before retrying.

Help me troubleshoot this ModuleNotFoundError

Open the chat on your iPhone

Your laptop is now serving the chat interface to any device on the same Wi-Fi network. Your iPhone can reach it by navigating to the laptop's IP address.

  • On your iPhone, open Safari.
  • In the address bar, type http:// followed by your laptop's IPv4 address, then :8000. For example: http://192.168.1.42:8000.
  • Tap Go on the iPhone keyboard.

You should see the dark-themed Deck chat interface with the input field and Send button at the bottom.

  • Type a message like Hey, who are you? and tap Send.

Deck should respond in-character with its personality. You will see tokens streaming in one by one, creating a typing effect.

Cannot connect from your iPhone?

Windows Firewall may be blocking incoming connections on port 8000. You need to create an inbound rule to allow it.

  • On your laptop, press the Windows key and type wf.msc. Press Enter to open Windows Defender Firewall with Advanced Security.
  • In the left pane, select Inbound Rules.
  • In the Actions pane on the right, select New Rule.
  • Select Port and click Next.
  • Select TCP, enter 8000 under Specific local ports, and click Next.
  • Select Allow the connection and click Next.
  • Check all three profiles (Domain, Private, Public) and click Next.
  • Name the rule Deck Server and click Finish.

Try loading the page on your iPhone again. It should connect now.

Help me troubleshoot my iPhone connection to the Deck server

Why does this work over Wi-Fi?

The host="0.0.0.0" setting in server.py tells FastAPI to listen on all network interfaces, not just localhost. This means any device on your LAN (your Wi-Fi network) can reach the server using your laptop's IP address.

Test the memory system

The real test of a companion is whether it remembers you. You will save a fact using the /remember command, restart the server, and confirm Deck still knows it.

  • In the chat on your iPhone, type the following and tap Send:
/remember My name is [[YOURNAME="your first name"]]

You should see a confirmation message appear in the chat saying Saved: followed by the fact you entered.

What just happened?

The /remember command sends a POST request to the /memory endpoint on your server. The server appends the fact to memory.json.

Next time Deck starts a conversation, it reads this file and injects the facts into its system prompt. That is how Deck "remembers" across restarts.

  • Switch back to your laptop terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the server by running the same command:
python server.py
  • On your iPhone, reload the page in Safari (pull down to refresh, or tap the address bar and tap Go again).
  • Type What's my name? and tap Send.

Deck should respond with your name. The memory survived the restart because it is stored in memory.json on disk, not in the server's RAM.

Deck forgot your name?

Open memory.json in VS Code and check that it contains your fact. It should look like ["My name is ..."]. If the file is still an empty [], the /remember command did not reach the server.

Make sure you typed the slash command exactly as shown, with no extra spaces before the slash.

Help me debug Deck's memory system

Secret mission

Add Automatic Memory Extraction

Right now, Deck only remembers things when you explicitly use the /remember command. What if Deck could pick up on important facts during normal conversation and save them automatically? In this mission, you will teach Deck to detect personal facts in its own responses and persist them to memory without any manual commands.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The deck project folder on your Desktop (contains server.py, index.html, memory.py, memory.json, soul.txt, and the venv virtual environment).
  • The llama3.1:8b model downloaded in Ollama (approximately 4.9GB of disk space).
  • A Windows Firewall inbound rule for port 8000 (if you created one during Step 5).

Keep everything running

No action needed. Choose this if you plan to keep chatting with Deck or want to add more features.

  • Your deck folder, virtual environment, and Ollama model stay on disk ready for next time.
  • The Deck server is not running unless you start it manually with python server.py, so nothing consumes resources while idle.
  • The firewall rule for port 8000 remains active. This only allows devices on your local Wi-Fi to reach that port while the server is running.

Pause - I'll come back to this later

Shut down the server process to free up memory. Your files and model stay on disk so you can resume later.

  • If the Deck server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate your virtual environment by running:
deactivate
  • Your project folder, model, and memories all remain intact for next time.

Delete - I don't want to use this again

Remove all project files, the downloaded model, and any firewall changes to start fresh.

  • If the Deck server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate your virtual environment by running:
deactivate
  • Delete the entire project folder by running:
cd ~/Desktop
rmdir /s /q deck
  • Remove the Ollama model by running:
ollama rm llama3.1:8b
  • If you created a Windows Firewall rule for port 8000, remove it by opening an elevated Command Prompt and running:
netsh advfirewall firewall delete rule name="Deck Port 8000"

Not sure of the rule name?

If you named your firewall rule something different, replace Deck Port 8000 with whatever name you used when creating it. You can also open Windows Defender Firewall with Advanced Security, find the rule under Inbound Rules, right-click it, and select Delete.

Nice Work!

Nice Work!

Nice work! You just built a personal AI companion from scratch, running entirely on your own hardware with zero ongoing costs.

You've learned how to:

  • Build a FastAPI server with WebSocket streaming that connects to a local Ollama LLM running on your GPU.
  • Design a persistent personality system using a soul file and implement a JSON-based memory system that survives server restarts.
  • Serve a mobile-friendly chat interface over your local network and connect to it from your iPhone over Wi-Fi.
  • Secret Mission: Added automatic memory extraction so Deck learns personal facts from conversations without being explicitly told.

Ready to quiz yourself?