AI characters who text you first — fully local, six dependencies, runs on a Raspberry Pi.
A character/companion messaging app that looks and feels like texting: add characters the way you'd add phone contacts, chat 1-on-1 or in groups, from any browser on your network. Your characters have memories, moods, and small ongoing lives of their own — they reach out when they would, not when a cron job says so. Everything stays on your hardware.
- Tiny footprint — Python + SQLite + vanilla JS, no build step, no GPU required. Deps: fastapi, uvicorn, httpx, ddgs, python-multipart (+ pywebpush).
- Bring your own model — any OpenAI-compatible endpoint: Ollama, llama.cpp server, LM Studio, or a cloud API. Built for small local models (1B–4B) — tool use works through prompt markers, not JSON function calls, because tiny models can actually do that reliably.
- Private by architecture — chats, memories, and your profile never leave your machine. There is no cloud, no account, no telemetry.
- Characters are just folders —
characters/<id>/soul.mdis the personality, in plain markdown. Edit it in any text editor. Zip it. Share it. - Your SillyTavern cards work here — import any V2/V3 PNG character card (chub.ai, etc.) with the ⇪ button; embedded lorebooks come along for the ride. Export back to a dual-spec card that works in every client.
- They text like people. Replies arrive as several short bubbles with typing pauses, not one paragraph. Characters react to your messages with emoji tapbacks. DMs show delivered/read receipts.
- They text first. Each chat learns its own rhythm; a character reaches out when the silence stretches past what's normal for you two. Night owls text at 3am. Needy characters double-text. Mention a job interview on Thursday and they'll check in Thursday evening — they schedule it themselves.
- They have lives. Every character runs small ongoing storylines (mostly ordinary: errands, a friend's birthday, a project) written in their own voice. Ask "what are you up to?" any day and the answer is consistent — and their first-texts reference what's actually going on.
- They have moods. A rough conversation leaves a mark for a day; a good one does too. Mood colors their tone and nudges how soon they reach out.
- They know each other. Characters occasionally share offstage moments — a coffee, a call — that land in both their memories. Kai might mention he ran into Luna yesterday. Nobody scripted it.
- They remember. A shared profile about you, private per-character memories that span DMs and group chats, rolling conversation summaries so long relationships survive a 4k context, and full-history recall with dates. All of it plain markdown you can open, edit, or delete.
- They can look things up (keyless DuckDuckGo by default) instead of making things up — and send you real photos from the web when it fits the moment.
git clone https://github.com/CZ140/textlings.git && cd textlings
python -m venv .venv
.venv/bin/pip install -r requirements.txt # Linux / Pi / macOS
# .venv\Scripts\pip install -r requirements.txt # Windows
.venv/bin/python -m app.mainOpen http://localhost:8080 — on your phone, use your computer's LAN IP instead.
By default it talks to Ollama at http://localhost:11434/v1. Change the
endpoint, model, and everything else from the ⚙ settings screen in the app
(or copy config.example.json to config.json and edit it — the app runs
fine without one, using built-in defaults). config.json is gitignored
because the settings screen stores your LLM API key in it.
Any chat model on an OpenAI-compatible server works. Good Pi-class options:
| Model | Size | Notes |
|---|---|---|
ministral-3:3b |
3B | recommended default — stayed concise + in-voice across registers and held distinct group voices in testing (where qwen3:4b caricatured); lightest alignment of the majors, has vision |
qwen3:4b-instruct |
4B | reliable on tool markers, but caricatures richer souls — walls of text, repetition, group voice-collapse |
hermes3:3b |
3B | Nous Research — "system prompt is sovereign" steerability; whatever the soul says, it plays |
gemma4:e4b |
4.5B eff. | great writer but most safety-restricted, and crashes some Ollama versions (ollama#16506) |
llama3.2:3b |
3B | fast, solid character voice |
qwen3:1.7b |
1.7B | for Pi 4 / low RAM |
Characters can each run a different model (model in character.json / the
editor) — keep everyday characters on the default and give specific characters
a more expressive or capable one. scripts/model_probe.py <model...> tests any
model for tool-marker obedience and staying-in-character under pressure.
Avoid thinking model variants (plain
qwen3:4b, deepseek-r1, etc.) — they burn the whole reply budget reasoning and produce empty texts. Use-instructtags.
In the app: Contacts → +, fill in name, tagline, greeting, and their soul (personality, voice, boundaries — plain markdown), add a picture. Done.
Or let the AI do it: Contacts → ✨ opens the builder. Either answer a short
interview about who you want them to be, or type "model them after Sherlock
Holmes" and it researches the subject on the web first. The draft opens in the
normal editor for you to review and tweak — nothing is saved until you say so.
Set "builder": {"model": "..."} in config.json to use a bigger model just for
character creation (drafting a great soul is the hardest job in the app).
Or by hand — create a folder under characters/:
characters/mira/
soul.md # personality, voice, example texts — plain markdown
character.json # {"name": "Mira", "tagline": "...", "greeting": "..."}
avatar.png # optional
memories.md # appears over time — what Mira has learned about you
story.md # appears over time — what's going on in her life
mood.md # appears over time — how she's feeling today
Share a character with the Export .zip button (full fidelity: memories, story, mood) or Export card (SillyTavern-compatible PNG that works in any client). Import either format — zips or community PNG cards — with the ⇪ button in the Contacts header. (Note: the bundled samples Luna and Kai are tracked in git; any characters you create are gitignored by default — they're yours.)
Got a bad reply? Long-press (or right-click) any bubble to regenerate, edit, or delete it.
| Layer | Where | What |
|---|---|---|
| Shared profile | data/user.md |
Facts every character knows about you. Edit it in ⚙ settings ("About you"). |
| Private memories | characters/<id>/memories.md |
What that character has learned — persists across DMs and group chats. |
| Chat summary | SQLite | A rolling summary of older messages, so long chats keep their middle on a 4k context. |
| Full recall | SQLite full-text index | Characters can [recall: ...] anything ever said, with dates. |
Characters save memories themselves mid-chat (you'll see "✨ Luna will remember
that"), and a background pass reviews idle conversations and files away anything
durable — including how the conversation left them feeling (mood.md).
Everything is plain markdown you can edit or delete.
Characters can reach out on their own — but on their terms, set per character in the contact editor:
- Texts first (initiative) — never / rarely / sometimes / often. Each chat learns its own rhythm (how often you text) and a character only reaches out once the silence stretches well past normal — hours for a daily chat, days for an occasional one.
- Awake hours —
9-23for a day person,20-4for a night owl who sends "you up?" at 3am. Wraps midnight. - Double texts — patient characters send one text and wait; needy ones double- or triple-text with growing gaps, and their last one can acknowledge the silence ("ok it's been 6 hours, i see how it is").
They also schedule their own check-ins from conversation: mention an interview on Thursday and they'll text Thursday evening to ask how it went. Characters are time-aware everywhere — real gaps between messages are visible to them, so they can notice you've been gone two days (when it fits their personality).
Global guardrails live in ⚙ Settings → Behavior (or config.json under
proactive): rolling daily cap across all characters, minimum silence floor,
and a master on/off switch. Once a character hits their unanswered-text limit
they go fully quiet until you reply.
Every character lives through small ongoing stories — usually ordinary stuff
(errands, a friend's birthday, fixing something) written in their voice, in
characters/<id>/story.md. Beats advance over hours/days, finished stories get
archived, and characters spend honest idle stretches "between things."
They also have moods (mood.md — one line, expires after a day) and
occasionally share offstage moments with each other: once in a while two
characters grab a coffee or trade a call, and the moment lands in both their
memory files from each one's perspective. The cast is a small social world,
not a row of isolated chatbots.
- Send them photos — 📷 in the composer. Vision-capable models (most Ollama multimodal models) actually see the image; text-only models honestly say they can't instead of guessing.
- They send photos back — characters can share real photos from the web ("the waves today 🌊" + an actual beach shot) when it fits the moment or their story. No selfies — they know they can't take pictures of themselves.
Characters' first-texts — and replies that land while the app is closed — can
show up as real push notifications, and you can use the app from your phone even
when you're away from home. Both need an HTTPS origin (browsers only allow
the service worker push depends on over HTTPS or localhost; plain
http://<host>:8080 won't do).
Tailscale solves both at once, for free, without exposing anything to the public internet — it builds a private network between your own devices and serves the app over real HTTPS. That fits the privacy premise: no cloud relay, the traffic only ever goes between your machines.
One-time setup:
- Install Tailscale on the host running Textlings and on your phone, signed
into the same account (Pi/Linux:
curl -fsSL https://tailscale.com/install.sh | shthensudo tailscale up; Windows/macOS/phone: https://tailscale.com/download). - Enable HTTPS for your tailnet once in the admin console: turn on MagicDNS and HTTPS Certificates.
- Serve the app from the host (Textlings already running on its port):
This prints an
tailscale serve --bg 8080
https://<host>.<tailnet>.ts.net/URL that proxies to your local app and works from any device on your tailnet, including your phone on cellular. (Settings → Notifications detects an insecure connection and hands you this exact command for your port.) - Install the PWA: open that
…ts.netURL on your phone and add it to the home screen (iOS Safari: Share → Add to Home Screen, then open it from the icon — required for iOS notifications; Android Chrome: menu → Install app). - Enable notifications: ⚙ Settings → Enable notifications on this device and accept the prompt. The toggle is per-device — enable it once on each.
Use Tailscale Serve (above), not Funnel — Serve keeps the app private to your own devices; Funnel would expose this no-auth app to the whole internet. For desktop testing, plain
http://localhost:8080is already a secure context, so notifications work there too.
Tap a notification and it deep-links straight to that chat. If nothing arrives, confirm the host stays awake — push is delivered by the server process, so a sleeping machine sends nothing.
# on the Pi (64-bit OS recommended)
sudo apt install python3-venv
git clone https://github.com/CZ140/textlings.git /home/pi/textlings && cd /home/pi/textlings
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
# install Ollama and a small model
curl -fsSL https://ollama.com/install.sh | sh
ollama pull ministral-3:3b # default; qwen3:1.7b for a Pi 4 / low RAM
# set the model in config.json, then:
sudo cp deploy/textlings.service /etc/systemd/system/
sudo systemctl enable --now textlingsNow any phone/laptop on your network can chat at http://<pi-ip>:8080.
Tips for the Pi:
- Run
python scripts/doctor.pyfirst — it checks the AI engine is reachable, the model is installed, and the context window is set high enough (a common silent-truncation trap), in plain language. The same checks back the in-app health probe (GET /api/health/full). - Keep
context_tokensat 4096 andmax_output_tokens≤ 512. - The background memory extractor waits for chats to go idle, so it never
competes with live replies. Turn it off in ⚙ Settings → Behavior (or
"memory": {"extract_enabled": false}) for zero extra inference.
This app has no real authentication. It binds to 0.0.0.0, so anyone on
your network can read your chats and edit your characters.
- Keep it on a trusted home LAN, or access it remotely through Tailscale/WireGuard instead of port-forwarding.
- As a courtesy lock (not real auth), set
"server": {"auth_token": "something-long"}inconfig.jsonand open the app once ashttp://<host>:8080/?token=something-long.
This is a single-user app for adults, by design. It has no content filter beyond what your chosen model brings — what your characters are like is entirely up to the souls you write.
app/ FastAPI backend
engine.py reply loop: streaming, tool markers, multi-bubble delivery
director.py group-chat speaker selection + reply budget
memory.py user.md, memories.md, [remember:], extraction, summaries
scheduler.py proactive texting: followups + rhythm pings
story.py character storylines mood.py daily mood
offstage.py characters' offstage moments with each other
context.py token budgeting for small context windows
tools/ web search providers (ddgs, SearXNG)
web/ vanilla JS messaging UI (no build step)
characters/ your characters — plain folders (samples tracked, yours ignored)
data/ SQLite db + user.md (created at runtime, never in git)
scripts/ smoke tests + offline check scripts (no server needed)
tests/ pytest suite (runs in CI; no server or model needed)
deploy/ packaging pipelines: Windows installer + Pi appliance image
PACK_FORMAT.md character pack spec — how to build shareable packs
AGPL-3.0. Run it, change it, share it — if you offer a modified version as a network service, share your changes too.
