Proactive texts — V0

DRAFT — will change

Anna texts you first. Not once a day — throughout the day, the way a chatty friend does. The nudge isn't "come back to the app"; the text is the practice. This doc is the design: how she decides when to reach out, why it's a sweep and not a scheduled job, and how "frequent" stays "friend" instead of "spam."

6 min
Scope revision: the V0 onboarding doc parked proactive texts under "Later / retention." We're splitting that bucket. The chatting half — a friend who texts you first to keep the conversation (and the exposure) going — moves into V0. The teaching half — resurfacing your own phrases as spaced repetition — stays V1 (the Anki backbone).
Design
  1. The thesis — a friend who texts first
  2. What's V0 here, what's V1
  3. The mechanism: sweep, don't schedule
  4. Cadence: persistent (model B)
  5. Why frequent isn't spammy
  6. What she actually says
  7. Hypothesis: let the user set it
Plan
  1. The build (sketch)
  2. Explicitly not in V0
  3. Open questions

Design

The thesis — a friend who texts first

The whole bet is that you learn a language from a person who's around, not from an app you open. A real friend doesn't wait for you to text first. They text you when something reminds them of you — and they do it more than once a day.

That's the feature. Anna reaches out on her own, several times across the day, and every message is real conversation in two languages. So you get exposure without ever "starting a session" — you just have a friend who keeps texting, and some of it's in Japanese.

It's the anti-Duolingo. Duolingo nudges you toward a thing you're avoiding — "keep your streak," then you go grind a lesson. The nudge and the value are different objects, and the nudge is a guilt tax. Here they're the same object: you don't "go practice," you got a text from Anna, and replying is the practice. Even a text you ignore was a sentence of Japanese in front of your eyes.

What's V0 here, what's V1

Proactive texting splits cleanly. V0 is the relationship engine — she initiates, she keeps it warm, she keeps exposure flowing. V1 is what she's smart enough to say — resurfacing the exact phrases you fumbled, scheduled by an SRS.

V0 — now

  • Anna texts first, multiple times a day, in waking hours.
  • Openers drawn from memory — pick a thread back up, ask how a thing went, bring a little Japanese.
  • Anti-annoying cadence: chatty when you engage, eases off when you don't.
  • Re-engages existing threads (anyone who's texted in before).

V1 — teaching layer

  • Texts that resurface your own phrases — the SRS / spaced-repetition loop.
  • Near-miss detection — "you almost said it; say it better."
  • Scheduling by memory decay, not just elapsed silence.
  • Cold-opening a brand-new thread (texting you first-ever).

Key line: V0 decides when to text from the clock and the thread; V1 decides what to text from a model of what you're forgetting. Same delivery path, different brain. Build the delivery path now.

The mechanism: sweep, don't schedule

We weighed three ways to trigger an outbound text:

ApproachHowVerdict
Schedule after "session close"Detect the conversation dying down, queue a job for ~later.Needs a fuzzy "session ended" event. Lost on restart.
Fixed timer, cancel on replyArm a timer; an incoming message cancels and re-arms it.Cancel bookkeeping on every inbound. Timers die on restart.
Sweep + check pickA slow tick walks every thread and asks: due to text right now?Simplest, restart-safe, one testable function.

The insight collapsing the first two into the third: you never need to detect a "session close" — "quiet for N hours" is the session-close signal. And you never need to cancel a timer — an inbound message just resets last-activity, so the next tick naturally sees "too soon" and skips.

A slow setInterval (~every 15–30 min) sweeps each thread. For each it checks one predicate — quiet long enough + inside waking hours + not ghosting — and if true, composes an opener from memory and sends it. We already run exactly this shape: the inbound poller is a 1 s setInterval. Proactive is just a second, slower one. No new infra.

Cadence: persistent (model B)

The proactive gap is only a floor. When you're replying, the live back-and-forth rides on top of it (that's the normal reactive path — it doesn't count as proactive). So the floor sets "if it's gone quiet, how long until she pokes," and real conversation fills the rest.

KnobV0 default
Quiet gap before a poke (the floor)~2–3h + random jitter, so it's never on a clean clock
Waking hourslocal 9:00–21:00, never outside
Ignored-streak behaviorPersistent — keeps lightly poking through the day; resets each morning
Sweep intervalevery ~15–30 min (just the checker; not the send rate)

We considered two personalities for the ignored case. We're shipping B.

A — friend (deferred)

~2 unanswered → quiet till tomorrow morning. Respects that you're busy; lowest annoy-risk; less exposure.

B — persistent shipping

Keeps lightly poking ~every couple hours through the day regardless, resetting each morning. Clingy-but-charming; maximum exposure. The Duolingo-daily instinct, minus the guilt.

B leans into "throughout the day." It carries more annoy-risk, so the guardrails below matter — and the user-tuning hypothesis (§7) is the pressure valve.

Why frequent isn't spammy

This is the open question the onboarding doc flagged and didn't answer: "what governs proactive cadence so it's sticky, not spammy?" The answer is to make the cadence asymmetric rather than to make it slow:

Three hard guardrails make B safe:

  1. Waking hours. Nothing outside 9–21 local. No 3am texts, ever.
  2. Daily reset, not escalation. An ignored day doesn't ramp up or sulk into permanent silence — it just starts fresh tomorrow.
  3. Every text carries value. Each message is real Japanese + an answerable question. An ignored one was still exposure, not a "u up?" nag. This is what earns the right to be frequent.

What she actually says

Same brain as a reply — the persona and memory from core/agent.mjs, just kicked off without an inbound message. She pulls a real thread from memory, reacts to nothing in particular the way friends do, brings a little Japanese, and ends on something easy to answer. Not drilling your phrases back — that's V1.

2:40 PM · she texts first
anna
ok i just had the worst conbini coffee 😭
how's the move stuff going, did you book the flight yet?
you
not yet lol. soon
anna
はやく!笑
when you do, tell me the dates — 楽しみ(たのしみ)

She didn't say "let's practice." She just texted, and now you're in Japanese again — because answering is the natural move. The exposure is the message, not a lesson attached to it.

Hypothesis: let the user set it

A competing model worth testing: instead of us picking the cadence, the user does. Anna asks — during onboarding or the first time she reaches out — something like "when do you want to hear from me? mornings? a few times a day?" — and honors it.

Why it might win

Consent and control kill the annoy-risk outright. It's the Duolingo reminder-time, but conversational. People who opt into "text me a lot" won't find a lot annoying. Gives ownership of the relationship's rhythm.

Why it might not

Friction up front; people don't actually know what they want until they feel it. A fixed "chat time" can feel scheduled and un-spontaneous — the opposite of a friend who texts when you cross their mind.
Likely synthesis: ship B as the default so there's zero setup, and make it tunable by just telling her — "text me less," "only mornings," "you're texting too much" — parsed into a stored cadence preference the sweep reads. Natural-language cadence control: no settings screen, the relationship adjusts the way a real one does. Default carries the cold-start; the user's own words override it whenever they want.

Open for V0: do we proactively ask (the user's hypothesis), or only listen for them to volunteer it? Asking is more explicit but adds an onboarding beat. Leaning: don't ask up front — ship B, listen for adjustments. Revisit if B tests as too much.

Plan

The build (sketch)

Small. One new module, light touches to three existing files. No new dependencies.

1
The sweep core/proactive.mjs — a setInterval that walks all learners and applies the due-check (quiet gap + jitter, waking hours, ignored-streak). All the when lives here. Started from server.mjs, reusing its typing-dots choreography so a proactive text sends like a human reply.
2
Compose an opener core/agent.mjs — a sibling to respond() that builds the same persona prompt with a "reach out first" stage-direction and no inbound message. Returns reply bubbles. Shares the tool loop with respond().
3
Track activity core/memory.mjs — per-thread: chatGuid (so we can send), last-inbound / last-outbound timestamps, an unanswered-poke counter, and (for §7) an optional cadence preference. The chatGuid is captured from inbound, which is why V0 re-engages existing threads only.
4
Knobs config.mjs / .env — enabled, waking-hours window, quiet gap, jitter, sweep interval. So cadence is tunable without a code change (set the gap to minutes to watch it work).

Explicitly not in V0

Open questions