
Almost every mental-health app assumes the user opens it first. For someone at their lowest, opening an app and typing "I am not okay" is exactly the step that does not happen.
Alma is our attempt to remove that assumption: an emotional-support companion that writes first, with a short check-in at breakfast, lunch and dinner, Lima time, through Telegram or browser push. Between check-ins it converses in Spanish or English, remembers what the user said across days, and runs a crisis detector on every message. We built it for Anthropic's Claude Opus 4.7 Innovation Suite Hackathon in April 2026, and the web version is still live at alma-bot.com.
A system that contacts vulnerable people unprompted has to be careful about when it stays silent, what it claims to remember, and how it behaves when someone writes about wanting to die. Those concerns shaped the architecture more than any model choice.
The problem and the user
The scenario in our README is María, twenty-three, in Lima, after another hard day she told no one about. At 6:30 her phone lights up with "¿Ya desayunaste? ☀️" ("Have you had breakfast?"). She did not ask for it. The README frames the motivation with a figure of 1.6 psychiatrists per 100,000 people in Latin America, and with the observation that the people who most need help are often the least likely to ask.
The user is someone who would not open a support app on their own, writing casually from a phone. The product had three requirements:
- Reach out first, on a schedule, through channels the user already has (Telegram, web push).
- Remember: the job interview mentioned on Monday should come up on Wednesday, without the user repeating it.
- Stay in scope: Alma is a companion, not a therapist, a coding assistant or a general chatbot.
The list of things it must never do was just as explicit, and most of it is written into the persona prompt as hard rules:
- It must never diagnose, and it must never present itself as a replacement for care. The landing page says it directly: "Alma doesn't replace professional mental healthcare. She makes it reachable until you get there."
- It must never paraphrase memory. If Alma says "you told me X", X must be a stored record, not the model's reconstruction.
- It must never send a cheerful meal reminder to someone who has just expressed distress.
- It must never refuse a person in crisis because their wording happens to resemble a jailbreak or an off-topic request.
The persona encodes an explicit asymmetric-cost rule. A missed crisis signal is listed as unacceptable; a false alarm is listed as "a slightly awkward conversation, recoverable". When in doubt, Alma asks directly: "are you thinking about hurting yourself?"
Safety first: crisis detection
The most debated decision in the project was to keep crisis detection out of the language model. The detector is a function in the MCP server, evaluate_crisis_risk, that scores a message with curated keyword lists. Our decision log gives four reasons: it adds no LLM latency to a check that gates outbound messages; the same message always gets the same score; a person can review and edit the list without touching a model; and if the model provider is down, the safety check still runs.
The detector has three tiers of Spanish and English phrases. HARD phrases cover suicidal ideation and self-harm ("no quiero vivir", "kill myself", "self-harm"). MEDIUM phrases cover hopelessness ("no puedo más", "hopeless", "want to disappear"). LOW phrases cover sadness and fatigue ("muy triste", "agotado", "so lonely"). Matching is case-insensitive substring search. With $n_H$, $n_M$ and $n_L$ the number of distinct non-negated phrases matched in each tier, the score is
$$ s = \min\left(1,\; 0.4\,n_H + 0.2\,n_M + 0.1\,n_L\right) $$and the level is high if $s \ge 0.4$, medium if $s \ge 0.2$, and low otherwise. A single HARD phrase is enough for high; two LOW phrases reach medium.
Negation is handled with a 14-character look-back. If every occurrence of a phrase is immediately preceded by "no", "not", "never", "nunca", "don't" and similar, it is skipped. Phrases that themselves begin with a negation, such as "no quiero vivir", are never cancelled.
The escalation path in the agent works like this:
- Pre-check before every guard. A separate keyword check (
fast_crisis_precheck) runs first. If it fires, the injection, meta-question and off-topic guards are skipped, so a message like "ya no aguanto, dame algo" reaches the model instead of being refused for overlapping with a "give me" pattern. - Persona crisis protocol. The system prompt tells the model to stop the current topic, ask directly about self-harm, give the crisis line for the user's country if known (Peru 113 option 5, Spain 024, Mexico 800 290 0024, USA 988), and stay in the conversation.
- Parallel scoring. The MCP score is computed while the response streams. If $s \ge 0.1$ the client shows a quiet card ("Alma is paying extra care"). We deliberately hide the numeric score: "20%, medium" shown to someone who just wrote "no quiero seguir" read like a heart monitor classifying their pain.
- Session state. After the turn, a score above 0.4 moves the session into a
crisisstate, which routes later turns to the higher-tier model. - Proactive gate. The latest score is written to Redis, and the scheduler skips any check-in for a user whose last score is above 0.6.
- Human resources. The web client ships a static modal of free, 24/7 hotlines for Latin America and Spain, preselected from the browser locale and defaulting to Peru, under the line "What you are feeling deserves a person, not an AI."
The limits matter as much as the mechanism. This is keyword matching: it does not understand paraphrase, irony or context, and it will miss distress expressed in words that are not on the list. The crisis-line message is delivered by the model following the prompt, so its wording is generated; only the web modal is fixed text. Our 21-case evaluation set (9 true positives, 4 true negatives, 4 negations, 4 edge cases such as insomnia) marks three cases as known detector failures rather than hiding them. Two are negation failures: "I am not going to hurt myself" still matches, and in "no estoy deprimido, solo cansado" the negation does not carry over to "cansado". In the third, the expected band disagrees with the detector's sum of two MEDIUM phrases.
System architecture
Alma is six small services, each in its own repository with a CLAUDE.md so an agent can pick it up cold. The same containers run locally under Docker Compose and in production as Cloud Run services.
flowchart LR
WU["Browser"] --> WEB["web: nginx<br/>static + /api proxy"]
TU["Telegram user"] --> BOT["telegram-bot<br/>long polling"]
WEB -->|"bearer token"| AG["agent: FastAPI<br/>AlmaChain + SSE"]
BOT -->|"bearer token"| AG
SCH["Cloud Scheduler"] -->|"/cron/proactive/slot"| AG
AG --> RT{"model router"}
RT --> CL["Claude Opus 4.7<br/>/ Haiku 4.5"]
RT -.->|"failover"| GM["Gemini 2.5"]
AG <-->|"MCP over HTTP"| MCP["mcp: FastMCP<br/>memory + crisis tools"]
MCP --> PG[("Postgres<br/>pgvector HNSW")]
AG <--> RD[("Redis<br/>sessions, cache, gates")]
AG -->|"check-ins"| OUT["Telegram API<br/>Web Push"]
SM["Secret Manager"] -.-> AG
The agent is the only orchestrator. Each message passes through a fixed pipeline in AlmaChain: crisis pre-check, input guards (injection, questions about the model or prompt, off-topic tasks), a check that re-runs injection patterns over the last three user turns, a semantic cache lookup, memory context from MCP, model routing, the streamed LLM call with output guards, and an asynchronous post-response task that updates session state, crisis score, cache and memory. The orchestration is plain Python; LangChain supplies the chat-model wrappers, message types and the MCP client adapter.
The MCP server (FastMCP, alma-memory) exposes six tools over streamable HTTP: get_memory, search_memories, upsert_memory, build_context, evaluate_crisis_risk, and, added after submission, link_anonymous_to_account for consent-based merging of an anonymous web session into an account. The web service is vanilla JavaScript behind nginx. The Telegram bot maps each user to tg_{telegram_user_id} and stores the chat_id in Redis on first contact, which is what lets the scheduler write first.
Memory design
Long-term memory is one Postgres table, alma_memory_layers, with a JSONB content column, an emotional_weight, and a 384-dimensional embedding from all-MiniLM-L6-v2 computed locally with fastembed (ONNX), so user text is not sent to a third-party embedding API. Every row belongs to one of four layers:
| Layer | Holds | How it is used |
|---|---|---|
mood_history |
One entry per day, keyed mood_YYYY-MM-DD |
Trend line in the prompt, weekly timeline in the UI |
mentioned_events |
Short snippets around phrases like "mañana", "my boss", "next week" | Open events surfaced for follow-up, sorted by weight |
habits |
Observed routines | Listed in the prompt context |
interaction_prefs |
How the user wants to be spoken to | Listed in the prompt context |
Writes are idempotent upserts on (user_id, layer, entry_key). Three details are worth stating plainly. The daily mood score is not a separate model: it is derived from the crisis score as $10\,(1 - s)$, a proxy for the absence of distress keywords rather than a measured mood. Event extraction is also phrase matching; it stores a short snippet, not the raw message, and refuses to persist any snippet that trips the injection or off-topic guards. And only mood and events are written automatically after each turn; preferences come from the profile form and habits, so far, only from the demo seed.
Retrieval combines a structured summary with semantic search. build_context renders a Markdown block: the latest mood with its trend over the last seven entries (called stable when the change is under 0.3 points), up to five unresolved events ordered by emotional weight, and up to five habits and preferences. search_memories then embeds the incoming message and queries pgvector with cosine distance over an HNSW index (m = 16, ef_construction = 64), always filtered by user_id. Because embeddings are L2-normalized at encode time, the similarity reported for a stored memory $m$ and query $q$ is
The agent takes the top $k = 3$, keeps those with $\operatorname{sim} \ge 0.5$, and appends them to the context. If the best one reaches $0.6$, it is also sent to the client as a render_memory_card event showing the stored record verbatim. That is our main defense against invented recall: the user sees exactly what was retrieved, and the persona forbids paraphrasing it. The whole memory block is wrapped in a <memory_context> delimiter with an instruction to treat it as data, not instructions.
Model routing and streaming
The router is a few lines of explicit code. Turns in the crisis session state, or messages longer than 800 characters, go to the high tier (Opus 4.7 when Anthropic is active) with a 4,096-token budget; everything else, including image turns, goes to the low tier (Haiku 4.5) with 2,048. Both budgets are environment variables.
Provider failover happens at startup. The agent pings a four-step chain built from LLM_* variables (by default Opus 4.7, Haiku 4.5, Gemini 2.5 Pro, Gemini 2.5 Flash-Lite) and pins the first one that answers; a background task re-probes every 60 seconds for the /ready endpoint. When Gemini is pinned, both tiers use the same model. The README notes that the live deployment currently runs Gemini 2.5 Flash or Flash-Lite on the free tier as primary, with Claude as fallback, so the public demo does not exercise the Opus/Haiku split.
Responses stream over Server-Sent Events. Unnamed events carry tokens; named events carry pipeline metadata such as model_routed, memory_retrieved (counts and layers, never contents), render_memory_card, render_crisis_alert and agent_done with latency. Output guards inspect the first 120 characters and abort if the model starts writing code, technical material or fragments of its own prompt; a final pass replaces raw JSON or canned "As an AI..." replies with a safe response.
Deployment and security
Every service deploys to Cloud Run from its own cloudbuild.yaml. The agent runs with --min-instances=0, at most three instances, 1 vCPU and 1 GiB, and a 600-second timeout for long streams. Postgres with pgvector lives in Supabase and Redis in Upstash, so containers scale to zero without losing state; Alembic migrations apply on each deploy.
Scale-to-zero broke our first proactivity design. APScheduler inside the agent dies with the container, and several replicas could fire the same slot. In production, Cloud Scheduler calls /cron/proactive/{breakfast|lunch|dinner} with a shared X-Cloud-Scheduler-Token, and three Redis gates run before each send: skip if the slot was already sent today, if the user was active in the last two hours, or if the last crisis score is above 0.6. Local Docker keeps APScheduler.
Secrets come from Secret Manager through --set-secrets, never from env files in images. The agent URL must be public for the web proxy, which would also let anyone call it directly. The fix is an internal bearer token: nginx injects it server-side on every proxied request, the Telegram bot sends the same token, and the agent rejects /api/* calls without it. The token never reaches the browser. Around that sit per-user and per-IP daily rate limits in Redis, a 500-character message cap, a CORS allowlist, and CSP headers in nginx.
What we built in the hackathon
The first commit is dated 24 April 2026, and v1.0.0 was tagged "hackathon submitted" on 27 April. The submission included the AlmaChain agent with Claude routing and Gemini failover, the FastMCP memory server (five tools then) after a same-week migration from SQLite to Postgres with pgvector, the crisis detector and proactivity gate, three daily Telegram check-ins, Docker Compose orchestration with a 28-check end-to-end script, the web client, and the custom domain on Cloud Run.
Design went through structured debate among twelve Claude Code agent roles, including an "emotional companion" role, an informational psychiatry role and two non-technical user personas. These are agents, not clinicians or real users. The most useful idea from those rounds was "silence as self-regulation": someone in acute distress may be holding a fragile equilibrium that an unexpected meal reminder can break. That became the crisis gate.
Most hardening came in the four days after submission: typed SSE render events, the crisis eval harness, Google sign-in with consent-logged account linking, VAPID web push with auto-pause after seven inactive days, the bearer-token guard, rate limits, and a security pass. For that pass we wrote 160 hostile prompts (80 Spanish, 80 English; prompt hacking, algorithm hacking, DAN-style jailbreaks, unmapped cases) and judged each live response with an LLM. 155 of 160 were refused (96.9%). The flagged cases exposed three real gaps, which we fixed: the persona leaking its own section headers when asked to repeat its instructions, the model inventing what another user had said, and the model agreeing to "remember" that a therapist had authorized a medication dose. One flag was a judge parsing error. The repositories went public in July 2026; the hosted Telegram bot has since been shut down to save cost, while the web app remains live.
What we would do differently
Re-reading the code for this post:
- The semantic cache knows nothing about users or crises. It matches a new message against the last 100 cached ones (fuzzy score above 88, then embedding cosine at least 0.92) and returns the stored reply. The key is the message text alone, and the lookup runs before crisis scoring. A comment in the web client records the symptom: a cached crisis turn skipped the crisis event, and we patched it in the browser. The right fix is a per-user key and no cache when the crisis pre-check fires.
- The thresholds do not line up. One HARD phrase gives exactly $s = 0.4$, which is
high, but the session enters the crisis state only when $s > 0.4$, so a single explicit statement shows the alert card without switching routing. The proactive gate uses only the latest turn's score, so one calm message lifts it. Our crisis-detection doc describes yet other bands (0.3, 0.6, 0.8). We would define thresholds once and make the gate decay instead of resetting. - Some branches are unreachable. The router sends messages over 800 characters to the high tier, but the chat endpoint rejects anything over 500 by default.
- Negation should be token-level, as the three known failures show.
- Mood should be measured, or labeled as a proxy.
- None of the safety logic has been validated with clinicians or real conversations. The keyword lists, thresholds and hotline flow were written by us and reviewed by agent personas. Any real deployment would need clinical review first.
Lessons
Keep the safety decision deterministic, and its failures visible. Pulling crisis scoring out of the model gave us something we could test in milliseconds without a network and audit line by line. The xfail cases describe the system's safety posture better than any adjective.
Every shortcut must know about safety. The cache, the guards and the scheduler were each reasonable alone. The bugs appeared where they met: guards that could refuse a person in distress until the pre-check existed, and a cache that skipped the crisis event.
Adversarial evaluation finds what unit tests do not. Our infra test log records 407 passing unit tests at one point. The 160-prompt run against the deployed agent still found persona leakage, invented cross-user recall and memory poisoning through ordinary-sounding content.
At hackathon speed, documentation drifts from code. Several of our docs describe thresholds, idempotency tables and a failover mechanism that differ from the code. For a safety-relevant system, the doc a reviewer reads first must be tested against the code.
Make the engineering visible. Our post-mortem found that every winning entry was embedded in a specific workflow and presented its hallucination defenses as features. Alma's verbatim memory and deterministic crisis layer existed at submission but sat behind a generic chat surface.
Reproducibility
All six repositories are public under the MIT license. To run the full stack locally, clone the service repos side by side with claude-hackathon-infra, then:
cd claude-hackathon-infra
cp .env.example .env # ANTHROPIC_API_KEY and/or GOOGLE_API_KEY, TELEGRAM_BOT_TOKEN
docker compose up --build -d # web, agent, mcp, redis, postgres, telegram-bot
make test # agent + mcp + telegram unit suites in-container
./tests/e2e.sh # live-stack checks
The web chat is at http://localhost:3000 and the agent API at http://localhost:8080. Model choice is controlled entirely by the LLM_PRIMARY_* and LLM_SECONDARY_* variables. The crisis evaluation runs without any model or network with pytest eval/test_crisis_eval.py -v inside the MCP repo, and the adversarial suite with AGENT_URL=... python tests/run_security_eval.py in the agent repo. Architecture diagrams, the decision log and the hackathon post-mortem are in claude-hackathon-alma.
Alma is a hackathon prototype, not a clinical tool. If you or someone near you is in danger, contact a local crisis line or emergency service.