Iris is a self-hosted safety monitor for kids' WhatsApp. It watches the chats and groups of one or more kids' numbers through OpenWA, classifies text, voice notes, audio, images and video for harmful content, and alerts a parent over WhatsApp with the kid, the chat, the sender and the quoted message.
It is built for mixed Hebrew and English chats (nothing is hardcoded to a language), runs as a single Docker container (amd64 and arm64), and uses free or low-cost models wherever possible.
Read this first. Monitoring a child's messages is a sensitive decision. Use Iris openly and only for children you are responsible for, and make sure you are allowed to do so where you live. Iris is read-only: it never replies or interacts in a chat. It only sends alerts to the number you choose.
- Features
- How it works
- Requirements
- Installation
- Connecting OpenWA
- Configuration
- Using the portal
- API
- Metrics
- Privacy and security
- Troubleshooting
- Development
- License
- One webhook per number. Each kid's WhatsApp session gets its own signed webhook; one number is one kid.
- Text is checked with OpenAI Moderation (a free endpoint).
- Images and stickers (with their caption) go through multimodal moderation.
- Voice notes, audio and video are transcribed (OpenAI or Cloudflare Workers AI, your choice), then the transcript is moderated.
- Context-aware second look. A message that is borderline is re-checked together with the previous messages in the same chat. If it is still unclear it lands in a review queue instead of a guess.
- WhatsApp alerts with the kid, chat, sender, category, score, time and the quoted message. A per-chat cooldown avoids floods and the next alert says how many were held back.
- Both sessions monitored? A message between two monitored kids is stored once, with both kids on it, and produces one alert.
- Edits and deletes are kept. A message the sender deletes for everyone stays in Iris with a red border and a "Deleted for everyone" label. An edited message gets an Edited marker, and the original wording and every earlier version stay available as an edit history. Iris tells you on WhatsApp when the message of an alert you already received is edited or deleted.
- Try it page. Type a message, see how Iris scores and classifies it, and adjust the thresholds with an instant preview before saving them.
- SQLite, PostgreSQL or MySQL. SQLite is the default and needs nothing; pick a database server under Settings > Database and copy your data across with one button.
- Searchable archive (Hebrew and English; SQLite full-text, or substring search on PostgreSQL and MySQL) with filters, a chat-style context view and match highlighting.
- A modern, responsive portal. A sidebar on desktop, an icon rail on tablets, and a bottom tab bar on phones, so an alert link opens into something you can use one-handed. Light and dark themes follow your system, and the whole portal passes an automated accessibility scan (keyboard, contrast, screen readers).
- Sexual content safety rule. Content clearly involving minors, and sexual imagery, is withheld entirely: it is not stored, not searchable, not shown and not forwarded. The alert says to review the chat directly. When Iris is only unsure about a text or voice message (a low score), it keeps the words so you can read them in the review queue and decide. It is never copied into an alert or sent to WhatsApp, and confirming it as harmful withholds it at that moment.
- Hidden until you look. Stored content (message text, alert quotes, kept media) is hidden by default and shown with an eye button; a switch under Settings > Account makes this browser show it by default.
- Keep the media if you want to (off by default). Store the photo or voice note behind an alert on the server's disk or in S3-compatible storage (Cloudflare R2, AWS S3, SeaweedFS, MinIO); the alert and the dashboard link to it.
- Self-hosted and private. Secrets are encrypted at rest, logs never contain message text, media is deleted after processing unless you turn on keeping it, and old data is removed automatically.
- Prometheus metrics, a REST API with interactive docs, and a multi-arch image.
flowchart LR
OW["OpenWA<br/>(one session per kid)"] -- "POST /webhooks/<token>" --> IN
subgraph iris ["iris container"]
IN["Ingest<br/>validate, dedupe, store, enqueue"] --> Q[("Job queue<br/>SQLite")]
Q --> W["Workers"]
W --> M["Media<br/>download, ffmpeg"]
M --> T["Transcription<br/>OpenAI / Cloudflare"]
W --> C["Classification<br/>moderation, then context"]
T --> C
C --> A["Alert service<br/>cooldown, redaction"]
A -- "send-text" --> OW
UI["Portal + REST API"] --- DB[("SQLite, PostgreSQL<br/>or MySQL")]
end
A -. "WhatsApp message" .-> P(("Parent"))
The webhook handler never calls an external API: it validates, stores, queues and answers 200. All slow
work (downloading media, transcribing, moderating, alerting) happens in worker tasks inside the same process.
Iris runs as one process on purpose: the queue and its locks assume it.
- Docker, on amd64 or arm64.
- An OpenWA 0.24.0 or newer server with one running session per monitored number. Older versions cannot download media that the session receives (see Troubleshooting).
- An OpenAI API key for moderation (the moderation endpoint is free but rate limited). It is also used for transcription unless you choose Cloudflare.
- Optional: a Cloudflare account ID and API token for Workers AI transcription.
- A public hostname or IP for Iris that OpenWA can reach. OpenWA refuses to send webhooks to private
network addresses (
Destination address is not allowed), so a LAN IP will not work: use a reverse proxy or tunnel and expose only/webhooks/*if you can.
services:
iris:
image: techblog/iris:latest
ports: ["8080:8080"]
volumes:
- iris-data:/data # SQLite database; a named volume keeps non-root ownership
environment:
IRIS_SECRET_KEY: "" # openssl rand -base64 32
IRIS_ADMIN_USERNAME: admin # first run only
IRIS_ADMIN_PASSWORD: "" # first run only
IRIS_PUBLIC_BASE_URL: https://iris.example.com # what OpenWA can reach
restart: unless-stopped
volumes:
iris-data:openssl rand -base64 32 # paste into IRIS_SECRET_KEY, and back it up
docker compose up -dMore ready-made files, for SQLite, MySQL, PostgreSQL and OpenWA, are in docker-compose/.
Open the portal on port 8080 and sign in with the admin credentials. They are used only to create the first account; change the password under Settings → Account.
Back up
IRIS_SECRET_KEY. It encrypts every stored secret (API keys). Lose it and you must re-enter them.
The container runs as a non-root user (uid 10001), applies database migrations on start, and has a health
check on /api/health.
uv sync # Python 3.12
cd web && npm ci && npm run build && cd .. # builds the portal into app/static
IRIS_SECRET_KEY=... IRIS_PUBLIC_BASE_URL=http://localhost:8080 \
IRIS_ADMIN_USERNAME=admin IRIS_ADMIN_PASSWORD=change-me IRIS_DATA_DIR=./data \
uv run alembic upgrade head && uv run uvicorn app.main:app --port 8080ffmpeg and ffprobe must be installed (the Docker image includes them).
- In the portal go to Phones → Add a phone and enter the child's name, your OpenWA address, the OpenWA session ID (the full UUID, not the name) and an OpenWA API key that can use that session.
- Click Register in OpenWA. Iris subscribes a webhook to
message.received,message.sent,message.editedandmessage.revokedand sets a signing secret, so deliveries are verified with an HMAC. Running it again updates the webhook already pointing at Iris (it keeps any other events you added), so it never creates a duplicate. Phones registered before edits and deletes were supported need one more click. If registration says the destination is not allowed, yourIRIS_PUBLIC_BASE_URLis a private address (see Requirements). You can also paste the shown URL (https://…/webhooks/<token>) into OpenWA by hand. - Repeat for every number you monitor.
- Under Settings → Alerts choose the instance that sends alerts, enter the parent's number
(international format, digits only, e.g.
972501234567) and press Test. A real WhatsApp message is sent using the values you typed, before you save.
New webhook address cuts the old URL off immediately; register the new one afterwards. Each phone shows when it last received a message, or Nothing received yet, and the Home screen flags phones that never have. Use the switch to pause watching a phone without removing it.
| Variable | Required | Default | Purpose |
|---|---|---|---|
IRIS_SECRET_KEY |
yes | 32 bytes, base64. Encrypts secrets at rest and derives session and webhook signing keys. Iris will not start without it. | |
IRIS_PUBLIC_BASE_URL |
yes | Externally reachable base URL, used to build webhook URLs and alert links. | |
IRIS_ADMIN_USERNAME |
first run | Initial admin user. | |
IRIS_ADMIN_PASSWORD |
first run | Initial admin password (stored hashed with argon2; ignored afterwards). | |
IRIS_DATA_DIR |
no | /data |
SQLite database (unless another database is chosen), the saved database choice, temporary media and, if you keep media on this server, the media folder. |
IRIS_DATABASE_URL |
no | postgresql://user:password@host:5432/db or mysql://user:password@host:3306/db (or sqlite:///path). Overrides the choice made in Settings, which then becomes read only. Add ?ssl=true for TLS. |
|
IRIS_PORT |
no | 8080 |
Listening port. |
IRIS_WORKERS |
no | 3 |
Concurrent job workers. |
IRIS_LOG_LEVEL |
no | INFO |
DEBUG, INFO, WARNING, ERROR. |
IRIS_LOG_JSON |
no | false |
JSON log lines. |
IRIS_METRICS_TOKEN |
no | unset | If set, /metrics requires Authorization: Bearer <token>. |
IRIS_FORWARDED_ALLOW_IPS |
no | 127.0.0.1 |
Your reverse proxy's IP, so the login rate limit sees real client addresses. |
By default Iris keeps everything in one SQLite file in its data folder. To use a database server instead, open Settings > Database:
- Create an empty database and a user for Iris on your PostgreSQL or MySQL server. On MySQL create the
database with the
utf8mb4character set. - Pick the type, enter the host, port, database name, user and password (turn on Use TLS if the server supports it) and press Test connection. Iris tells you whether it can reach the server, whether the login works and whether the database is empty.
- Press Save. The choice is stored in
database.jsonin the data folder (the password is encrypted withIRIS_SECRET_KEY) and Iris uses it from the next start. Restart Iris, for Dockerdocker restart iris. - To keep your history, press Copy my data to PostgreSQL (or MySQL) before restarting. It copies phones, settings, messages, alerts and the edit history into the empty database, checks the row counts, and leaves the current database untouched, so the old SQLite file is a backup. Messages that arrive while it copies are not included, so copy when it is quiet and restart right after.
Use SQLite again forgets the saved choice. If IRIS_DATABASE_URL is set the page only shows what is in use,
because the variable wins.
Notes:
- Only one Iris process may use a database (the job queue and locks live in that process), whichever database it is.
- Message search on PostgreSQL and MySQL matches any part of a word and ignores case, and works for Hebrew and English; on SQLite it uses the full-text index, which matches the start of words. The results page looks the same.
- Iris creates its tables itself (migrations run at start). It never creates databases or users.
- A different
IRIS_SECRET_KEYcannot read the saved database password or the encrypted settings. - The copy reads one consistent snapshot of the current database, so rows written meanwhile cannot break it. If it fails part-way, Iris empties the new database again and you can simply retry.
By default Iris deletes every photo and voice note as soon as it has been checked. Under Settings > Media you can turn on keeping copies, so you can look at what triggered an alert.
-
What to keep: only what Iris judges harmful; harmful and needs a look (the review queue too); or everything. The decision is made after the check, so a "harmful only" setting never stores the rest. Videos are not kept at all: Iris checks what a video says, not what it shows, so it cannot promise that a sexual video would be withheld.
-
Where: this server's disk (the
mediafolder inside the data folder, so in Docker the data volume) or S3-compatible storage. Create the bucket first, then enter the endpoint, bucket, access key and secret key and press Test storage: Iris writes, reads back and deletes a tiny file.Service Endpoint Region Notes Cloudflare R2 https://<account id>.r2.cloudflarestorage.comautoCreate an R2 API token with object read and write for the bucket. AWS S3 https://s3.<region>.amazonaws.comthe bucket's region, e.g. eu-west-1Turn path-style addresses off if your bucket needs bucket.hostaddresses.SeaweedFS http://<host>:8333any, e.g. us-east-1Start the S3 gateway with an identity that has the access key and secret. MinIO http://<host>:9000any, e.g. us-east-1 -
How long: media has its own limit, 30 days by default. Older files are deleted from the storage; the message and its alert stay until their own limits under Retention. If the storage is unreachable, Iris keeps trying until the file is gone.
-
Where you see it: the WhatsApp alert gets a line such as
📎 Media kept (image, 1.2 MB): https://…/media/12; the alert page shows the photo or plays the voice note; the alert list and the dashboard mark alerts that have media and the dashboard shows how many files and how much space they use.Alerts with kept media The media page 

How it stays safe:
- Withheld content is never kept. Media of a message withheld by the safety rule (anything sexual involving minors, or sexual images and stickers) is not stored whatever you choose, and if a later check withholds a message, its kept copy is deleted. Media Iris could not check (a failed conversion or transcription) is not kept either.
- Only after you sign in. The link in the alert opens an Iris page that needs your login (it lasts 7 days on a phone). Iris streams the file itself, with a strict content type, so it is never served straight from the bucket and nothing in the WhatsApp text can open it on its own. Keep the bucket private.
- Only real photos and audio are kept, recognised by their bytes and matched to the message type. Documents and videos are not kept.
- A storage problem never blocks checking or alerting: the alert simply goes out without the link.
- Copying your data to another database does not move the files: they stay in the data folder or the bucket and keep working.
- Each file remembers which storage it was written to, so changing the bucket later does not strand old files; Delete all kept media (Settings > Media) removes everything, also after you turned keeping off.
Everything else is edited under Settings and stored in the database. Secrets are write-only: the API and UI only ever say whether one is set.
| Tab | Setting | Default |
|---|---|---|
| Providers | OpenAI API key | |
| Providers | Transcription provider (openai or cloudflare) |
openai |
| Providers | OpenAI transcription model | gpt-4o-mini-transcribe (or whisper-1) |
| Providers | Cloudflare account ID, API token, model | @cf/openai/whisper-large-v3-turbo |
| Classification | Moderation model | omni-moderation-latest |
| Classification | Per-category thresholds (low and high) | see below |
| Classification | Context window / max age | 8 messages / 6 hours |
| Alerts | Sender instance, recipient, cooldown, time zone | cooldown 10 min, Asia/Jerusalem |
| Alerts | Also alert on items needing review | off |
| Alerts | Tell me when an alerted message is edited or deleted | on |
| Scope | Monitor messages sent by the kid, direct chats, groups | all on |
| Retention | Keep messages / alerts | 90 / 365 days |
| Media | Keep media: off, harmful, harmful and needs a look, everything; where; how long | off, this server's disk, 30 days |
Switching the transcription provider takes effect immediately, with no restart.
Thresholds. For each moderation category a score at or above high is harmful; between low and
high it is inconclusive and gets the context check; below low it is safe. Defaults are strictest for
sexual/minors (0.05 / 0.30) and self-harm (0.10 / 0.40), and loosest for general harassment, hate, illicit
and violence (0.20 / 0.70). Iris decides from the category scores, not from the API's own flagged flag.
The iris ring answers the first question: all violet means all quiet; coral arcs are alerts waiting for you and saffron arcs are messages Iris could not decide. Below it, Needs attention lists everything that needs you or needs fixing (unread alerts, items to review, undelivered alerts, failed jobs, phones that never reported) with a button for each, and the activity chart shows 14 days of messages by verdict. Everything refreshes about every minute. The chart is also available as a table.
While a page is open, Iris pushes changes to it, so new messages, alerts, review items and job failures appear without a reload. A Live indicator (Reconnecting… if the connection drops) shows that the page is updating by itself, and a new alert also shows a toast, A new alert needs you, with an Open button, and puts the number of unseen alerts in the browser tab title until you come back to it.
The stream never carries message content: it only says what changed (messages, alerts, stats, ...) and the page then asks the normal, signed-in API for the new data, so hide and show and withholding apply exactly as before. If a proxy blocks or buffers the stream, the page keeps working and still refreshes about once a minute.
Behind a reverse proxy, turn buffering off for /api/events (nginx: proxy_buffering off; or rely on the
X-Accel-Buffering: no header Iris sends) and allow long-lived responses. Iris sends a heartbeat every 20
seconds. Up to 20 portal tabs can listen at once. The stream lives inside the one Iris process, which is how Iris
is meant to run.
Everything Iris has stored of a message is hidden by default: message text and transcripts, alert quotes, kept photos and voice notes, and edit history. Hidden text appears as a blurred mask that contains none of the real characters, and hidden media is not even requested from the server. Press the eye to show it and again to hide it. Nothing is remembered between page views, unless you turn on Settings > Account > Show content by default (kept in this browser only), which makes every page start shown; the eye still hides it.
- On lists (Alerts, Messages, the Home recent alerts) one Show content button at the top reveals every row on that page.
- On a single alert, conversation, review card or media page the button sits next to the content.
| Hidden | Shown |
|---|---|
![]() |
![]() |
Withheld content cannot be shown, on purpose. If a message clearly involves a minor in a sexual context (or is a sexual image, sticker or video, or an image of a possible minor), Iris never stores it, because keeping it could be illegal, so there is nothing behind the eye. The alert says what was detected and tells you to open the chat directly in WhatsApp.
Each alert shows the kid, chat, sender, categories with scores, the quote and whether it was delivered. Open one to see how Iris decided, jump to the message in its conversation, mark it as seen or dismiss it, or send it again if delivery failed.
The WhatsApp alert looks like this:
⚠️ Iris alert
Kid: Noa, Dan
Chat: Class 6B (group)
From: Yonatan
Category: harassment (0.98)
Time: 06/10 17:14
"You are a worthless idiot, nobody likes you, just disappear"
Open: https://iris.example.com/alerts/12?s=…
Voice-note quotes are prefixed with 🎤 and image, sticker and video captions with 🖼️. The signed link at the end lets Iris recognise its own alerts if they come back through a monitored number, and it cannot be copied onto different text.
Search matches words and prefixes in messages and transcripts, in Hebrew and English, with filters for kid, chat, sender, type, verdict and dates. Open a message to see the surrounding chat with the message highlighted and its classifications.
Messages that stayed inconclusive even with the surrounding chat wait here, with their text (hidden until you press the eye, or shown if you turned on Settings > Account > Show content by default). Mark safe closes them; Mark harmful creates an alert and, if the message was flagged as involving a minor, withholds its text from then on.
When OpenWA reports that a message was deleted for everyone, Iris keeps it, draws a red border around it and adds Deleted for everyone (in words as well as colour). The sender removed it from the chat, but you can still read it here, and an alert for it stays.
An edited message shows an Edited marker. Open it in the conversation to see the edit history: the current text, every earlier version, and the original wording with when it was sent and replaced.
How Iris treats a change:
- The edited text is checked again and search finds the new wording. The verdict never improves because of an edit: a message that was harmful or in review keeps that verdict, so editing something into a harmless sentence does not hide it. A message that becomes harmful through an edit raises a normal alert.
- Content that was withheld (see the safety rule) is never copied into the history, and withholding a message also clears its earlier versions.
- With Tell me when an alerted message is edited or deleted on, an alert you already received gets a short WhatsApp follow-up (the kid, chat, sender and a link). It never repeats the message text.
- An edit or delete that reaches Iris before the original message was stored is ignored. WhatsApp does not say what an edit replaced, so the history holds what Iris saw; the edit time is when Iris received it.
| Phone | Dark mode |
|---|---|
![]() |
![]() |
Use Try it to tune the thresholds without waiting for a real message. Type some text, optionally add earlier lines from the chat (one per line, oldest first) and press Check. Iris asks OpenAI Moderation once and shows the score for every category, the band each one lands in, and the final verdict: fine, needs a look (review queue) or harmful (alert).
Then change the Needs a look and Harmful values on any row: the bands and the verdict update at once, without another request. The orange and red ticks on each bar mark the current thresholds. Save these thresholds stores them (only the rows that differ from the defaults); Reset to saved drops the preview. With earlier lines filled in, Iris also scores the second look, exactly like the real pipeline does for an unclear message.
The text you check is sent to OpenAI for moderation and is not stored or logged by Iris. It never appears in Messages, Alerts or the review queue. Checks are limited to 30 per 5 minutes.
| Phone | Dark mode |
|---|---|
![]() |
![]() |
Jobs lists work that failed, with the reason, and a Retry button. A message that failed shows failed
and its reason in the message list (it is never silently shown as pending).
The portal is built for phones first: alert links from WhatsApp open straight into it. On a phone the sidebar becomes a bottom tab bar (Home, Alerts, Review, Messages and a More sheet for Chats, Phones, Jobs, Settings, the theme and sign out), filters tuck behind one Filters button, and every control is a comfortable touch target.
![]() |
![]() |
![]() |
![]() |
![]() |
The portal follows your system's light or dark preference. Change it from the account menu (bottom of the sidebar) or the More sheet: System, Light or Dark. The choice is remembered in the browser.
All endpoints are under /api, return JSON, and need the session cookie from POST /api/auth/login,
except /api/auth/login, /api/health and /api/version. Interactive OpenAPI docs are at /api/docs and the schema at
/api/openapi.json, both behind the login.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/login, /api/auth/logout, /api/auth/password |
Session login (HttpOnly, SameSite=Strict, 7 days; 5 failures per 15 minutes per IP), logout, change password |
| GET | /api/auth/me |
The signed-in user (401 when not signed in) |
| GET | /api/health, /api/version |
Liveness (database and workers), version |
| GET | /api/stats |
Dashboard numbers |
| GET | /api/events |
Server-sent events: change (topics that changed) and alert (id of a new alert), never content |
| GET | /api/messages |
Search: q, instance_id, chat_id, sender, type, verdict, from, to, page, page_size (max 100) |
| GET | /api/messages/{id}, /api/messages/{id}/context |
One message with classifications; surrounding messages |
| POST | /api/messages/{id}/reprocess |
Re-queue classification (not for redacted messages) |
| GET | /api/alerts, /api/alerts/{id} |
List with filters (status, delivery_status, instance_id, chat_id, category, from, to, page, page_size); detail |
| PATCH | /api/alerts/{id} |
Set status to new, acknowledged or dismissed |
| POST | /api/alerts/{id}/resend |
Send the alert again (ignores the cooldown) |
| GET, POST | /api/review, /api/review/{message_id} |
Review queue; resolve as safe or harmful |
| GET | /api/chats |
Known chats with kids and counts |
| GET/POST/PATCH/DELETE | /api/instances[/{id}] |
Manage monitored numbers (API keys are never returned) |
| POST | /api/instances/{id}/rotate-token, /register-webhook |
Rotate the webhook token; register it in OpenWA |
| GET, PUT | /api/settings |
Read and write settings (thresholds are the key classification.thresholds) |
| GET | /api/settings/thresholds |
Effective per-category thresholds next to their defaults (read-only) |
| POST | /api/settings/test/{openai|cloudflare|alert|media} |
Test a provider with the values entered |
| GET | /api/media/{id}, /api/media/{id}/info |
A kept file (streamed by Iris, byte ranges supported, never from the bucket) and what it belongs to; 404 once it is deleted or withheld |
| POST | /api/classify/test |
Score typed text (max 4000 chars) with optional earlier context lines (max 20); nothing is stored; 30 per 5 minutes |
| GET, PUT, DELETE | /api/database |
Which database runs and which is saved for the next start (never the password); save a choice (409 when IRIS_DATABASE_URL is set); go back to SQLite |
| POST | /api/database/test |
Try a connection with the values entered (10 per 5 minutes) |
| GET, POST | /api/database/copy |
Progress of, and start, the copy of your data into the saved database |
| GET | /api/jobs |
Failed and dead jobs with their errors |
| POST | /api/jobs/{id}/retry |
Retry a failed or dead job |
| POST | /webhooks/{token} |
OpenWA delivers here (authenticated by the token, and by an HMAC signature once Iris registered the webhook) |
GET /metrics (Prometheus text format):
| Metric | Labels |
|---|---|
iris_webhooks_total |
instance, result (accepted, duplicate, skipped, rejected) |
iris_messages_processed_total |
type, verdict |
iris_stage_duration_seconds |
stage |
iris_provider_requests_total |
provider, endpoint, status |
iris_provider_duration_seconds |
provider, endpoint |
iris_transcription_seconds_audio_total |
provider |
iris_alerts_total |
category, delivery_status |
iris_jobs |
status |
iris_live_clients |
none (portal tabs listening for live updates) |
/metrics is open by default. Because OpenWA needs Iris's port for webhooks, that port may be reachable from
outside, so set IRIS_METRICS_TOKEN or restrict /metrics in your reverse proxy.
- Sign-in required for the portal and every API except health and version. Webhooks are authenticated by a 32-byte random, rotatable token and, once registered through Iris, an HMAC signature.
- Secrets are encrypted at rest (AES-256-GCM) and never returned by the API.
- Logs contain only IDs, types, categories, scores and timings, never message text, transcripts or media.
- Media is not kept unless you turn it on (Settings > Media). It is downloaded to a per-job temporary directory and deleted afterwards, also on failure and on shutdown; leftovers from a crash are swept at start-up. Only media with a recognised audio, video or image signature is passed to ffmpeg. If you do keep media, see Keeping media: withheld content is never kept, files are served only to a signed-in owner, and they expire on their own schedule.
- Sexual content is withheld. If a message involves minors at or above the high threshold, or is an image, sticker or video that involves minors (at any score) or is sexual, Iris clears its text and transcript, removes it from the search index, never quotes it in an alert, and refuses to reprocess it. A text or voice message that scores only in the uncertain band (above low, below high) is kept, hidden until you press the eye, so you can judge it in the review queue; marking it harmful withholds it at once, and marking it safe leaves it as it is. Kept media is never stored for any message flagged as involving minors.
- Retention. Messages older than the retention window are deleted hourly, except while an alert still references them; alerts are deleted after their own window; finished jobs after 7 days.
- Alert-loop protection. Iris recognises its own alerts by a signature over the whole alert text, so an alert that reaches a monitored number is not classified, while a look-alike typed by someone else is.
- Security headers (CSP,
X-Content-Type-Options,Referrer-Policy,X-Frame-Options) are set, and the container runs as a non-root user. - Use HTTPS in front of Iris. Session cookies are marked
SecurewhenIRIS_PUBLIC_BASE_URLstarts withhttps://.
Test storage fails, or alerts go out without a media link. Run Test storage under Settings > Media: it says whether the endpoint cannot be reached, the keys are refused, or the bucket does not exist. A failed upload never blocks checking or alerting; the media is simply not kept. For R2 make sure the token can write to the bucket, and for AWS check the region and the path-style switch.
Test connection says "Could not reach the server". Check the host and port from where Iris runs (in Docker,
localhost is the container itself: use the server's address or its compose service name) and any firewall.
"The user name or password was refused" and "That database does not exist" mean the server answered but the
login or the database name is wrong. Iris never shows the password in these messages.
Iris will not start and the log says it cannot reach the database, or that database.json cannot be read.
Iris never falls back to SQLite on its own, because new messages would then land in a different database than
your history. Start the database server, or restore the IRIS_SECRET_KEY the file was saved with. To give up
on the saved choice, delete database.json in the data folder (or set IRIS_DATABASE_URL=sqlite:////data/iris.db)
and restart.
I changed the database but Iris still shows the old one. The choice applies at the next start: restart Iris.
If Settings > Database is read only, IRIS_DATABASE_URL is set and wins.
Messages show failed with "OpenWA has no stored media…". OpenWA could not download the media of a
message the session received. This is a known bug in OpenWA before 0.24.0
(#1739): upgrade OpenWA, then press Reprocess on the
message. Text is unaffected.
"Destination address is not allowed" when registering the webhook. OpenWA blocks private-network
webhook targets. Put Iris behind a public hostname (reverse proxy or tunnel) and set IRIS_PUBLIC_BASE_URL
to it.
No alert arrived. Open the alert: Delivery says why (not configured, an OpenWA error, or
suppressed by the per-chat cooldown). Check that the sender session is running, then Resend. The
Test button under Settings → Alerts verifies the sender and recipient.
A phone shows "Nothing received yet". OpenWA cannot reach IRIS_PUBLIC_BASE_URL, or the webhook was
not registered. Check the URL from the OpenWA host.
A borderline message was flagged. Context can raise a score: a casual "you're dead meat, lol" after a tense exchange may be judged harmful. Dismiss the alert, or raise the threshold for that category.
Everything is slow or jobs pile up. The dashboard shows queue depth. Moderation is rate limited by
OpenAI: Iris retries with backoff and honours Retry-After. Raise IRIS_WORKERS only if the queue stays long.
uv sync
uv run ruff check . && uv run ruff format --check . && uv run mypy
uv run pytest # unit tests: no network, providers mocked
uv run pytest -m integration # real OpenAI (needs TEST_* variables, see .env.example)
cd web
npm ci && npm run lint && npm test && npm run build # builds into ../app/static
npm run dev # Vite dev server, proxies /api to :8080Layout: app/ is the FastAPI backend (ingest/, openwa/, jobs/, media/, transcription/,
classify/, alerts/, api/), web/ is the React portal, tests/ mirrors it with real captured and
sanitized OpenWA payloads under tests/fixtures/openwa/. New classification stages plug into
app/classify/stages.py.
CI runs lint, type checks, tests, the portal build, a security scan, and builds the image for both linux/amd64 and linux/arm64 on every pull request.
MIT License. See LICENSE.
The beta local deployment guide describes opt-in Ollama moderation, a native Apple Silicon transcription companion, parent recipient management and QR pairing. Existing cloud defaults remain unchanged. Read the guide's validation limits before deploying this beta for monitoring.





































