Hackathon · Agents for mental health

Alma: An AI Companion That Reaches Out First, With a Safety Layer That Never Guesses

How we built a proactive, bilingual emotional-support companion for the Claude Opus 4.7 hackathon, with four-layer pgvector memory, a deterministic crisis detector kept apart from the language model, and an honest list of what it still gets wrong.

Author
Cristian Lazo Quispe
Date
April 2026
Event
Claude Opus 4.7 Innovation Suite Hackathon
Links
Live demo · Code · Video
Host
Anthropic
Challenge
Build with Claude Opus 4.7 (open domain); our entry targeted proactive emotional support in Spanish and English
Team
iDeepBrain (Cristian Lazo Quispe), with a roster of specialized Claude Code agents for design review
Built with
Claude Opus 4.7, Claude Haiku 4.5, Gemini 2.5 (failover), FastAPI, LangChain, FastMCP, pgvector, fastembed, Redis, Cloud Run, Cloud Scheduler, Secret Manager, Telegram Bot API
Result
v1.0.0 submitted on 27 April 2026; not among the winners
Alma’s chat view (Spanish UI): the companion opens the conversation with context it remembered, and the side panel shows the memory it builds in real time.
Alma’s chat view (Spanish UI): the companion opens the conversation with context it remembered, and the side panel shows the memory it builds in real time.

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:

  1. Reach out first, on a schedule, through channels the user already has (Telegram, web push).
  2. Remember: the job interview mentioned on Monday should come up on Wednesday, without the user repeating it.
  3. 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:

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:

  1. 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.
  2. 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.
  3. 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.
  4. Session state. After the turn, a score above 0.4 moves the session into a crisis state, which routes later turns to the higher-tier model.
  5. 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.
  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

$$ \operatorname{sim}(q, m) = 1 - d_{\cos}(q, m) = \frac{q^\top m}{\lVert q\rVert\,\lVert m\rVert} $$

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:

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.