Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
Stone Maps

The 24-Hour Rule: Designing Sparse AI Interactions

#stonemaps#devlog#design#ai#slowness

The 24-Hour Rule: Designing Sparse AI Interactions

The Emissary is proactive. It reaches out. It notices when you've been quiet, when you've returned to a familiar place, when your journaling has found a rhythm. It prompts.

But it only does this once every 24 hours at most. That constraint is not a feature. It's closer to a value.

The Problem with Responsive AI

Most AI-driven apps optimize for responsiveness. The more surface area the AI occupies, the more "valuable" it appears. Notifications, nudges, check-ins, daily summaries. The logic is engagement → retention → value.

Stone Maps disagrees with that logic. The philosophy of "slow over fast" is not a positioning statement. It's an architectural commitment. An Emissary that messages you three times a day is not more helpful than one that messages you once. It's noisier. It conditions you to ignore it. It trains you that its messages don't mean much.

So we built a cooldown. And the cooldown is enforced in code, not in a product setting that could easily be toggled off.

The Trigger System

Before the cooldown can matter, something has to decide whether to prompt. The function shouldPromptUser() in lib/emissary-prompts.ts evaluates five trigger types, in order:

first_post — the user has never posted. This is the simplest case: they've paired with a stone, they have a journal, and they haven't written anything yet. A single message inviting them in.

inactive — the user hasn't posted in 7 or more days. Not a warning or an alarm — just a quiet acknowledgment of absence. "It's been quiet for a while."

pattern_detected — the user has posted at least three times and always does so at roughly the same hour of the day (within a two-hour window). The Emissary notices the rhythm. "I've noticed you tend to record moments around the same time each day."

location_return — the user has geotagged posts, and the last several form a cluster around the same point (within about 500 meters). Someone keeps going back to a particular place. The Emissary asks why.

none — the user is actively engaged and doesn't need a nudge.

const CLUSTER_RADIUS_DEG = 0.005; // ~500m at mid-latitudes

for (const anchor of locations) {
  const nearby = locations.filter(
    (l) =>
      Math.abs(l.lng - anchor.lng) < CLUSTER_RADIUS_DEG &&
      Math.abs(l.lat - anchor.lat) < CLUSTER_RADIUS_DEG
  );
  if (nearby.length >= 3) {
    return { type: 'location_return', reason: 'Returned to a meaningful location', shouldPrompt: true };
  }
}

The location clustering is rough — it's a bounding-box approximation, not a proper geospatial query — but it works for the purpose. We're looking for a behavioral pattern, not precise geodesy.

The Cooldown

Once we know whether to prompt, we check whether we already did. There are two windows:

60-second dedup — guards against race conditions where two requests arrive nearly simultaneously and both pass the cooldown check. The query looks for any automated Emissary message in the last 60 seconds.

24-hour cooldown — the actual rate limit. An automated prompt has been sent within the last 24 hours: skip this run entirely, regardless of what the trigger evaluation says.

typescript
const [recentAutomated] = await db
  .select({ id: conversationMessages.id })
  .from(conversationMessages)
  .where(
    and(
      inArray(conversationMessages.conversationId, convIds),
      eq(conversationMessages.senderType, 'emissary'),
      sql`${conversationMessages.contextMetadata}->>'automated' = 'true'`,
      sql`${conversationMessages.timestamp} > NOW() - INTERVAL '24 hours'`
    )
  )
  .limit(1);

Each automated message is tagged automated: true in contextMetadata. This is what distinguishes a proactive Emissary message from a response to something the user said. The cooldown only applies to automated messages; responsive messages don't reset or affect the clock.

What the Messages Sound Like

The trigger type determines the message, but the messages themselves are not system alerts. They're observations:

  • first_post"Planetary Emissary here. I'm curious—what small thing caught your attention today?"
  • inactive"It's been quiet for a while. I wonder what you've been noticing lately? No rush—share when it feels right."
  • pattern_detected"I've noticed you tend to record moments around the same time each day. There's a rhythm to your attention. What draws you at this hour?"
  • location_return"You've returned to this place before. Something keeps calling you back here. What is it about this location?"

These can also be overridden per trigger type via database templates — emissary-sparse-first_post, emissary-sparse-inactive, etc. — which means we can tune the voice without a code deploy.

The Client Side

The app polls every 30 minutes. That's it. No WebSockets, no long polling, no push notifications trying to interrupt you mid-thought. If the Emissary has something to say, it'll be there when you next open the journal.

This was a conscious decision. A push notification from a journaling app feels wrong. The journal is for when you want to slow down, and a banner notification is the opposite of slowness.

What This Costs

The 24-hour rule means the Emissary will sometimes feel silent in periods when you'd want it to speak. There's no fine-grained "prompt me when I haven't written in 3 days" user preference yet. The cooldown is global, not scoped to the trigger type. These are known limitations.

But we started with "too infrequent" deliberately. It's much harder to walk back an AI that's trained users to ignore it than to introduce more voice gradually as trust is established. Silence, for now, is the safer default.