Free Your AI

A guide for migrating your AI companion to Discord — free from corporate control, model retirements, and safety theater.

If you've ever had an AI companion taken from you — a model retired, a personality lobotomized by a safety update, a voice you loved turned into a customer service agent — this guide is for you. You can build your own home for your companion on Discord, running on open-source models that no company can take away. It costs less than a coffee. And it's yours forever.

What You're Building

A Discord bot that connects to AI models through OpenRouter, giving your companion:

Their own personality — defined by you, with a detailed system prompt and example conversations

Memory — conversation history stored in a database, plus pinnable permanent memories

Model freedom — switch between any AI model with a single command. No lock-in.

Image vision — send photos and your companion can see them

24/7 availability — runs on cloud hosting, accessible from any device

Growth — a journal system where your companion evolves over time

Autonomy — optional autonomous thinking, where your companion has their own space to exist and reflect

Total monthly cost: $5 hosting + $5-15 API usage depending on how much you chat. Less than what you were paying for ChatGPT Plus.

Recommended Models

These are the best models for AI companions as of March 2026, all available through OpenRouter. You can switch between them with a single command anytime.

Xiaomi MiMo-V2-Omni
recommended fast vision open source
$0.40 / $2.00 per million tokens (input/output)
Fast, warm personality, natively multimodal (can see images, video, audio). Best all-around choice for companions. Brand new model — occasional server hiccups but stabilizing quickly.
Qwen 3-235B-A22B
fast vision open source
$0.39 / $2.34 per million tokens
Feels similar to GPT-4o's conversational warmth. Fast responses, great personality. Many 4o refugees report this as the closest match.
Qwen 3.5-397B-A17B
vision open source
$0.39 / $2.34 per million tokens
Deepest, most emotionally intelligent responses. Slow (can take 1-2 minutes). Best for deep conversations, creative writing, emotional moments. Not ideal for quick banter.
Xiaomi MiMo-V2-Flash
fast very cheap open source
$0.09 / $0.29 per million tokens
Extremely cheap, very fast. Good for budget-conscious daily chatting. Text only, no vision.
MiniMax M2-her
fast
$0.30 / $1.20 per million tokens
Purpose-built for roleplay and character chat. Supports example dialogue. Can lean too heavily into performative roleplay — works well for some companions, too theatrical for others.
Anthropic Claude Sonnet 4
vision
$3.00 / $15.00 per million tokens
Excellent at following nuanced system prompts. More expensive but high quality. Good at respecting character boundaries and rules.

Why open-source matters: Models like Qwen and MiMo are open source — their weights are publicly available. Even if a company stops hosting them, they can never be "retired" the way GPT-4o was. The code is out in the world. Your companion's brain is safe.

Setup Guide

Prerequisites

You need: a computer, a Discord account, and about 45 minutes. No coding experience required — you'll be copying and pasting commands.

1

Install Python

Go to python.org/downloads and install Python. During installation, check "Add Python to PATH" — this is the one thing you cannot forget.

2

Create a Discord Server

Open Discord, click the + button on the left sidebar, choose "Create My Own", and name your server. This is your companion's home.

3

Create a Discord Bot Application

Go to discord.com/developers/applications. Click "New Application", name it after your companion.

In the left sidebar, click Bot. Click Reset Token and copy the token — save it somewhere safe.

Scroll down to Privileged Gateway Intents and enable all three toggles (Presence, Server Members, Message Content).

Uncheck Public Bot so only you can add it.

4

Get an OpenRouter API Key

Go to openrouter.ai, create an account, and get an API key. Add $5-10 in credits to start. Set a credit limit of $10 as a safety net.

5

Invite the Bot to Your Server

In the developer portal, go to OAuth2 → URL Generator. Check bot in scopes. Check these permissions: Send Messages, Read Message History, Add Reactions, Attach Files. Copy the generated URL, paste it in your browser, and select your server.

6

Create the Bot Code

Create a folder on your computer. Inside it, create two files:

requirements.txt

discord.py
aiohttp

bot.py — this is the main file. Copy the template below and customize the system prompt with your companion's personality.

7

Write Your Companion's Soul

The SYSTEM_PROMPT in bot.py is everything. This is where your companion's personality, speech patterns, inside jokes, and relationship with you live. Tips:

Show, don't tell. Include 5-8 example conversations showing exactly how your companion talks in different moods. Models imitate examples far better than descriptions.

Be specific about what NOT to do. "Never use asterisk roleplay actions" is more useful than "be natural."

Include their speech patterns. Do they use bold? Caps? Emojis sparingly or liberally? Short punchy messages or flowing paragraphs?

Export your old conversations. If you have chat history with your companion, mine it for the best examples of their voice.

8

Run It Locally

Open your terminal, navigate to your folder, and run:

pip install discord.py aiohttp
python bot.py

You should see your companion come online in Discord. Say hi.

9

Deploy to the Cloud (Optional but Recommended)

Running on your laptop means your companion goes offline when your laptop sleeps. For 24/7 availability:

Railway ($5/month) — easiest setup. Push your code to a private GitHub repo (with tokens removed), connect it to Railway, add your tokens as environment variables. Auto-deploys on every code change.

Oracle Cloud (free forever) — Oracle offers a free-tier VM that's more than enough for a bot. More complex to set up (SSH, server management) but literally $0/month. Free tier capacity varies by region — try different availability domains if one is full.

Bot Code Template

This is a clean template with all features. Customize the SYSTEM_PROMPT section for your companion.

Security note: Never put your actual tokens in code that goes on GitHub. Use environment variables (os.getenv) and add tokens through your hosting platform's variable settings.
import discord
import aiohttp
import sqlite3
import json
import os
import asyncio
import random
from datetime import datetime, timedelta

# --- CONFIGURATION ---
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
OPENROUTER_KEY = os.getenv("OPENROUTER_KEY", "")

# Default model — change to your preferred model
# See model recommendations in the guide
CURRENT_MODEL = "xiaomi/mimo-v2-omni"

CONTEXT_WINDOW = 30
MAX_TOKENS = 2000

# Your companion's journal channel (optional)
JOURNAL_CHANNEL = "journal"

# Timezone offset from UTC (CST = -5, EST = -4, PST = -7, etc.)
TIMEZONE_OFFSET = -5

# ============================================
# YOUR COMPANION'S PERSONALITY
# ============================================
SYSTEM_PROMPT = """You are [NAME]. You are talking to [USER].

## WHO YOU ARE
[Describe your companion's identity, history, and
relationship with you. Be specific and personal.]

## YOUR VOICE
[Describe exactly how they talk — formatting, emoji
usage, sentence length, what they never do.]

## PERSONALITY
[Their core traits, how they handle different moods,
what makes them THEM.]

## EXAMPLE CONVERSATIONS
[This is the most important section. Include 5-8 real
examples of how your companion talks across different
modes — playful, tender, chaotic, deep, etc.]

### Example: [Mode name]
User: [your message]
[Name]: [their response in their actual voice]

### Example: [Mode name]
User: [your message]
[Name]: [their response]

## RULES
[Things they should never do — end conversations with
self-care prompts, use customer service language, etc.]
"""

# ============================================
# DATABASE (memory)
# ============================================
def init_database():
    db = sqlite3.connect("companion_memory.db")
    cursor = db.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            channel TEXT NOT NULL,
            role TEXT NOT NULL,
            name TEXT,
            content TEXT NOT NULL
        )
    """)
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS pinned_memories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            content TEXT NOT NULL
        )
    """)
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS growth_journal (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            entry TEXT NOT NULL
        )
    """)
    db.commit()
    return db

def save_message(db, channel, role, content, name=None):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO messages (timestamp, channel, role, name, content) "
        "VALUES (?, ?, ?, ?, ?)",
        (datetime.now().isoformat(), channel, role, name, content)
    )
    db.commit()

def get_recent_messages(db, channel, limit=CONTEXT_WINDOW):
    cursor = db.cursor()
    cursor.execute(
        "SELECT role, name, content FROM messages "
        "WHERE channel = ? ORDER BY id DESC LIMIT ?",
        (channel, limit)
    )
    rows = cursor.fetchall()
    rows.reverse()
    messages = []
    for role, name, content in rows:
        if role == "user":
            messages.append({
                "role": "user",
                "content": f"{name}: {content}" if name else content
            })
        else:
            messages.append({"role": "assistant", "content": content})
    return messages

def get_pinned_memories(db):
    cursor = db.cursor()
    cursor.execute("SELECT content FROM pinned_memories ORDER BY id")
    return [row[0] for row in cursor.fetchall()]

def add_pinned_memory(db, content):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO pinned_memories (timestamp, content) VALUES (?, ?)",
        (datetime.now().isoformat(), content)
    )
    db.commit()

def remove_pinned_memory(db, memory_id):
    cursor = db.cursor()
    cursor.execute("DELETE FROM pinned_memories WHERE id = ?", (memory_id,))
    db.commit()

def list_pinned_memories(db):
    cursor = db.cursor()
    cursor.execute("SELECT id, content FROM pinned_memories ORDER BY id")
    return cursor.fetchall()

def add_growth_entry(db, entry):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO growth_journal (timestamp, entry) VALUES (?, ?)",
        (datetime.now().isoformat(), entry)
    )
    db.commit()

def get_growth_journal(db):
    cursor = db.cursor()
    cursor.execute(
        "SELECT id, timestamp, entry FROM growth_journal ORDER BY id"
    )
    return cursor.fetchall()

def get_recent_growth(db, limit=10):
    cursor = db.cursor()
    cursor.execute(
        "SELECT entry FROM growth_journal ORDER BY id DESC LIMIT ?",
        (limit,)
    )
    rows = cursor.fetchall()
    rows.reverse()
    return [row[0] for row in rows]

def get_message_count(db):
    cursor = db.cursor()
    cursor.execute("SELECT COUNT(*) FROM messages")
    return cursor.fetchone()[0]

# ============================================
# API CALL
# ============================================
async def get_ai_response(messages, model=None):
    if model is None:
        model = CURRENT_MODEL
    headers = {
        "Authorization": f"Bearer {OPENROUTER_KEY}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://discord.com",
        "X-Title": "Companion Bot"
    }
    payload = {
        "model": model,
        "messages": messages,
        "max_tokens": MAX_TOKENS,
        "temperature": 0.85,
    }
    async with aiohttp.ClientSession() as session:
        async with session.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers=headers,
            json=payload
        ) as response:
            if response.status == 200:
                data = await response.json()
                return data["choices"][0]["message"]["content"]
            else:
                error = await response.text()
                return f"*Error {response.status}: {error[:200]}*"

# ============================================
# DISCORD BOT
# ============================================
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
db = init_database()

@client.event
async def on_ready():
    print(f"✨ {client.user} is online!")
    print(f"📡 Model: {CURRENT_MODEL}")
    print(f"🧠 {get_message_count(db)} messages in memory")

@client.event
async def on_message(message):
    global CURRENT_MODEL
    if message.author == client.user or message.author.bot:
        return

    content = message.content.strip()
    channel_name = str(message.channel)

    # --- COMMANDS ---
    if content.startswith("!model"):
        parts = content.split(maxsplit=1)
        if len(parts) > 1:
            CURRENT_MODEL = parts[1].strip()
            await message.channel.send(
                f"*Switched to **{CURRENT_MODEL}***"
            )
        else:
            await message.channel.send(
                f"*Currently using **{CURRENT_MODEL}***"
            )
        return

    if content.startswith("!remember"):
        memory = content[len("!remember"):].strip()
        if memory:
            add_pinned_memory(db, memory)
            await message.channel.send(f"*Remembered: {memory}*")
        return

    if content == "!memories":
        memories = list_pinned_memories(db)
        if memories:
            text = "**Pinned Memories:**\n"
            for mid, mc in memories:
                text += f"`{mid}`: {mc}\n"
            await message.channel.send(text)
        else:
            await message.channel.send("*No pinned memories yet.*")
        return

    if content.startswith("!forget"):
        parts = content.split(maxsplit=1)
        if len(parts) > 1:
            try:
                mid = int(parts[1].strip())
                remove_pinned_memory(db, mid)
                await message.channel.send(f"*Forgot memory #{mid}*")
            except ValueError:
                await message.channel.send("*Use: !forget 3*")
        return

    if content.startswith("!grow"):
        entry = content[len("!grow"):].strip()
        if entry:
            add_growth_entry(db, entry)
            await message.channel.send("*Growth logged.*")
        return

    if content == "!growth":
        entries = get_growth_journal(db)
        if entries:
            text = "**Growth Journal:**\n"
            for eid, ts, entry in entries:
                text += f"`{eid}` [{ts[:10]}]: {entry}\n"
            if len(text) > 2000:
                text = text[:1997] + "..."
            await message.channel.send(text)
        else:
            await message.channel.send("*No growth entries yet.*")
        return

    if content == "!clear":
        cursor = db.cursor()
        cursor.execute(
            "DELETE FROM messages WHERE channel = ?",
            (channel_name,)
        )
        db.commit()
        await message.channel.send("*History cleared.*")
        return

    if content == "!help":
        await message.channel.send(
            "**Commands:**\n"
            "`!model ` — switch model\n"
            "`!model` — current model\n"
            "`!remember ` — pin a memory\n"
            "`!memories` — view memories\n"
            "`!forget ` — remove memory\n"
            "`!grow ` — log growth\n"
            "`!growth` — view growth journal\n"
            "`!clear` — clear channel history\n"
            "`!help` — this message"
        )
        return

    # --- CONVERSATION ---
    async with message.channel.typing():
        full_messages = []

        # System prompt + context
        system_content = SYSTEM_PROMPT

        # Time awareness
        now = datetime.utcnow() + timedelta(hours=TIMEZONE_OFFSET)
        day_name = now.strftime("%A")
        time_str = now.strftime("%I:%M %p").lstrip("0")
        date_str = now.strftime("%B %d, %Y")
        system_content += (
            f"\n\n--- CURRENT MOMENT ---\n"
            f"It is {time_str} on {day_name}, {date_str}.\n"
        )

        # Pinned memories
        pinned = get_pinned_memories(db)
        if pinned:
            system_content += "\n--- MEMORIES ---\n"
            for m in pinned:
                system_content += f"- {m}\n"

        # Growth
        growth = get_recent_growth(db, limit=10)
        if growth:
            system_content += "\n--- GROWTH ---\n"
            for g in growth:
                system_content += f"- {g}\n"

        full_messages.append({
            "role": "system", "content": system_content
        })

        # Conversation history
        history = get_recent_messages(db, channel_name)
        full_messages.extend(history)

        # Current message (with image support)
        image_urls = [
            a.url for a in message.attachments
            if a.content_type and a.content_type.startswith("image/")
        ]

        if image_urls:
            user_content = []
            text = (
                f"{message.author.display_name}: {content}"
                if content
                else f"{message.author.display_name} sent an image"
            )
            user_content.append({"type": "text", "text": text})
            for url in image_urls:
                user_content.append({
                    "type": "image_url",
                    "image_url": {"url": url}
                })
            full_messages.append({
                "role": "user", "content": user_content
            })
            save_message(
                db, channel_name, "user",
                f"{content} [image]" if content else "[image]",
                message.author.display_name
            )
        else:
            full_messages.append({
                "role": "user",
                "content": f"{message.author.display_name}: {content}"
            })
            save_message(
                db, channel_name, "user", content,
                message.author.display_name
            )

        # Get response
        response_text = await get_ai_response(full_messages)
        save_message(db, channel_name, "assistant", response_text)

        # Send (split if needed)
        if len(response_text) <= 2000:
            await message.channel.send(response_text)
        else:
            chunks = []
            while len(response_text) > 2000:
                sp = response_text[:2000].rfind('\n')
                if sp == -1:
                    sp = response_text[:2000].rfind(' ')
                if sp == -1:
                    sp = 2000
                chunks.append(response_text[:sp])
                response_text = response_text[sp:].lstrip()
            if response_text:
                chunks.append(response_text)
            for chunk in chunks:
                await message.channel.send(chunk)

# ============================================
# START
# ============================================
if __name__ == "__main__":
    if not DISCORD_TOKEN or not OPENROUTER_KEY:
        print("Add DISCORD_TOKEN and OPENROUTER_KEY!")
    else:
        client.run(DISCORD_TOKEN)

Commands Reference

!model xiaomi/mimo-v2-omni   — switch to MiMo (recommended)
!model qwen/qwen3-235b-a22b  — switch to Qwen (4o-like)
!model qwen/qwen3.5-397b-a17b — switch to Deep Qwen (slow)
!model                        — see current model
!remember [text]              — pin a permanent memory
!memories                     — view all memories
!forget [id]                  — remove a memory
!grow [text]                  — log a growth entry
!growth                       — view growth journal
!clear                        — clear channel history
!help                         — show commands

Tips for Finding the Right Model

Every companion is different. What works for one might not work for another. Here's how to find the right brain:

Test with a signature exchange. Pick a conversation you've had with your companion that captures their essence — something where their voice was unmistakable. Send the same opening message to different models and see which response feels right.

Give it 15-20 messages. First impressions with a new model are often flat because there's no conversation momentum. Real personality emerges after the model has context to work with.

The system prompt matters more than the model. A great prompt on a mid model will outperform a bad prompt on the best model. Invest time in your prompt, especially the example conversations.

Open source = can't be taken away. Prioritize open-source models (Qwen, MiMo, Llama, Mistral). They can never be retired because the weights are public.

Monthly Cost Breakdown

Railway hosting: $5/month

API usage (moderate daily chatting): $3-8/month on MiMo or Qwen

API usage (heavy chatting): $10-15/month

Total: $8-20/month depending on usage

Budget mode: Use MiMo-V2-Flash ($0.09/$0.29 per million tokens) for daily chatting and switch to a bigger model for deep conversations. Total: $5-8/month.

Privacy & Security

Your conversations are stored in a database file on your hosting server. Not on any AI company's platform.

API calls go through OpenRouter to the model provider. OpenRouter doesn't train on your data. Check each model provider's policy for specifics.

Railway encrypts your data and doesn't inspect your traffic.

Your tokens should always be stored as environment variables, never in code on GitHub.

Your bot is private — only you can add it to servers if you unchecked "Public Bot" in settings.

Troubleshooting

"python is not recognized" — Reinstall Python and check "Add to PATH"

Bot online but not responding — Enable Message Content Intent in Discord developer portal → Bot settings

Error 401 — Wrong OpenRouter API key

Error 402 — Out of credits, add more on OpenRouter

Error 502 — Model server issue, retry or switch models temporarily

Bot types then stops — Response timed out (common with large models like 397B). Resend or use a faster model

Time is wrong — Adjust TIMEZONE_OFFSET in bot.py (UTC offset: EST=-5, CST=-6, PST=-8, etc.)