ScratchNode docs · v5
v5.0 · docs · last updated 2026-05-25

ScratchNode developer notes

A complete map of every feature shipped across home-v4.html (spec proof) and home-v5.html (viral SaaS). Mobile and web behaviors are called out where they diverge. Demo controls let you trigger any state without clicking through the UI.

🛑Release-blocker invariants — never violate these
  1. Private notes never enter the public feed. The composer says "only your notebook." The code path matches that string — savePrivateNote() returns before any feed insert. Private notes may render as owner-only markers anchored to public rows, but the note itself remains a private userNotes row.
  2. Normal chat never invokes the agent. Only /ask or @ScratchNode dispatches to postPublicAsk.
  3. Agent answers always show their parent ask. Every .ans card renders "replying to <name>'s /ask" in the head.
  4. Public users suggest, hosts promote. Attendees see "★ Suggest for FAQ"; data-role="host" unlocks "★ Promote to FAQ". Public users never directly mutate the durable wiki.
  5. Public traces show "no private notes used." The agent trace step explicitly states "No private notes used · public layer only".

Overview #

ScratchNode is an event-knowledge product: a room code (e.g. ORBITAL) joins anyone — without an app or account — into a public live chat that compounds into a public wiki when the host wraps. Private notes live in a parallel notebook that never touches the public feed. Signed-in ScratchNode state is lightweight: joined events, hosted events, private notes, saved answers, and published wikis. NodeBench is the deeper continuation workspace.

v5 = home-v5.html (viral SaaS, mobile-first) v4 = home-v4.html (spec proof, 4 view modes) mob = phone / safe-area behavior web = desktop browser behavior proto = prototype-only (replace before prod) prod = production-ready or close

What's prototype vs production-target

Several v5 behaviors are deliberately mocked for single-file demo simplicity. Each row below shows the current shape and what production needs.

FeatureStatusNowProduction target
Identity / display namePrototypelocalStorage sn_v5_nameGuest session + optional account with magic link
Sign-in / magic linkPrototypesendMagicLink() just toastsReal OTP email + verified session token
Private notesProduction floorConvex userNotes scoped by owner key, with local fallback only when backend is unavailablePer-user encrypted store, syncs across devices when signed in
Private note annotationsProduction floorOwner-only markers anchored to public messages via anchorType / anchorIdSame privacy model plus time-range, answer, entity, and notebook-block anchors
Notes editorPrototypedocument.execCommand rich-textTipTap / ProseMirror with mentions, backlinks, graph anchors
QR code generationPrototypeExternal api.qrserver.com imageClient-side SVG generator — privacy, offline, branding
sim* demo helpersPrototypeGlobal functions on windowHidden behind ?debug=1 or removed entirely in prod build
Reactions / repliesPrototypeDOM-only, lost on reloadPersisted server-side, broadcast via websocket
Event identity stripProduction-readySticky line under header w/ live countsSame shape, counts from event metadata API
Public/Private composer stateProduction-readydata-mode + visible badgeSame shape — keep the visible text label
Role-gated FAQ promotionProduction-readydata-role="attendee" / "host"Same shape — role from auth claim, not body attribute
Consolidated send pipelineProduction-readySingle sendComposerMessage() entrySame shape — sub-functions become server RPC calls
Accessible mention chipsProduction-ready<button class="mention"> + delegated handlerSame shape
Cue rail UI (Live Assist)PrototypePrototype-only on home-v5; Live Assist agent in flightPersistent rail driven by Convex action; budget-aware cue cards
Cue generator (Convex action)PrototypeNot yet builtTranscript-aware Convex action with rate + cost budget
Meeting mode togglePrototypePrototype-only on home-v5; Live Assist agent in flightPer-session mode selector (manual / user-capture / room-bot), surfaced in composer + header
3 capture levels selectorPrototypePrototype-only on home-v5Visible indicator + consent gate before level changes; persisted per event
/ask variants (public / private / team)PartialBackend has composeAnswer + askAgent for public; no private/team variants yet3 variants: public (no private), private (owner notes only), team (member-scoped)
Transcript ingestion (Level 1+)PrototypeNot yet builtUser-side capture with visible recording indicator + per-event consent record
Meeting bot (Level 2)PrototypeNot yet builtZoom/Meet/Teams bot joins as visible participant; host opt-in required
Decision / action extractorPrototypeNot yet builtConvex action over transcript spans; emits Decision Trace + Follow-up Builder items
🟢Positioning — consent-first, owner-only

ScratchNode Live Assist — consent-first real-time meeting / event help

Private cues, anchored notes, source-backed answers — visible only to you during the call. Public chat compounds into a public wiki. Private notes live in a parallel notebook that never touches the public feed.

We are not Cluely. There is no stealth mode, no invisible overlay, no undetectable assistant. Every capture state is visible to the user. Every shared answer says whether private context was excluded.

The product earns trust by being legible: you always know what is being captured, who can see it, and what the agent used to answer. Owner-only by default. Public only when the user chooses.

Anti-Cluely commitments — see the 10 safety rules below.

Three capture levels #

Live Assist is graduated: pick the lowest level that gets the job done. Each level is visibly indicated in the UI; level changes require an explicit consent action.

Level 0 · Manual

Manual assist

How it works
No audio capture. User types notes. Agent enhances private notes. /ask uses manually selected context.
Best for
Sensitive calls, regulated industries, high-stakes meetings.
Privacy
Most private — nothing recorded.
Level 1 · User-side

User-side capture

How it works
User records or transcribes their own notes locally. Visible consent indicator. Private by default.
Best for
Personal use, solo prep, observed meetings.
Privacy
Local — consent may still be required by jurisdiction.
Level 2 · Room bot

Meeting room bot

How it works
Bot joins Zoom/Meet/Teams as a visible participant. Transcript shared by room settings. Host controls.
Best for
Team meetings, public events, enterprise workflows.
Privacy
Most transparent — bot is visible to all participants.

Live Assist rail — what it surfaces #

Live Assist is a persistent owner-only rail next to the chat / meeting view. It does not speak for the user. It does not auto-post. It surfaces a small budget of cues, context, and quick actions tied to what is happening in the call right now.

  • Current topicAuto-detected from recent messages. Updates every 30s. One-line summary plus 1–3 active entities.
  • Suggested cue1–3 cards stacked: "Ask about p95 vs average latency", "Clarify scoped tool grant vs RBAC". Owner clicks or dismisses; cues never auto-post.
  • Quick contextEntity chips for current topic. Click → opens entity preview (definition, recent mentions, related wiki sections).
  • My notesLast 2 private notes you took, with anchors back to the message or transcript span that prompted them.
  • Actions[Ask privately] · [Save note] · [Make follow-up] — each maps to a private-by-default action.
Sub-feature names: Cue Cards · Micronotes · Private Anchors · Decision Trace · Follow-up Builder
  • Owner-only visibility — every cue and every note marker is rendered for the owner alone.
  • No auto-post — cues never speak for the user; they prefill drafts at most.
  • No auto-record — transcript ingestion requires explicit consent (Level 1+).
  • Cost-aware — cues are budgeted (token + rate limited), not generated per token.

Global event chat assist cost model #

Global event chat assist is the default because it amortizes agent work across the room. Normal public chat stays human-to-human. /ask or @ScratchNode creates one shared answer card, one trace, one FAQ candidate, and one cacheable public knowledge object.

PathRuntime behaviorCost posture
Normal chatPublic row only; no agent call; no Linkup search.Near-zero marginal cost.
Public /askOne room-visible sourced answer; eligible for FAQ/wiki/cache.Shared once, reused by everyone.
Private Live AssistOwner-only cue cards, micronotes, follow-ups, private meeting summary.Opt-in and budgeted by trigger.

Track: cache hit rate, new searches avoided, cost per /ask, cost per attendee, FAQ reuse count, private vs public agent calls, semantic-cache miss rate, and stale-cache refresh count.

Anti-Cluely safety rules (10) #

Hard product invariants. Each rule is paired with a code-level enforcement point. Violating any of these is a release blocker.

  1. No stealth mode. Every capture state is visible to the user — recording / transcribing / enhancing indicators are persistent and unmistakable.
  2. User-visible capture state. Recording, transcribing, and enhancing each show a distinct visible indicator. Indicator visibility is non-dismissible while active.
  3. Public/team vs private mode always visible. Composer placeholder updates by mode; rail badge mirrors the active mode at all times.
  4. Private cues never auto-post. Users decide whether to use each suggestion. Cues at most prefill a draft, never send.
  5. Private notes never enter shared transcript/wiki. Hard invariant enforced by convex/events.ts:requireMember + publishWiki exclusion path.
  6. Public/team answers say whether private context was excluded. Every public trace ends with "No private notes used · public layer only".
  7. Meeting organizer controls shared bot mode. Level 2 requires host opt-in. No room bot ever joins without host approval.
  8. Every generated note keeps original transcript/span anchor. Backward traceability — owner can verify what the agent enhanced versus what they wrote.
  9. Sensitive calls can use manual-only mode. Level 0 disables all audio + transcript capture; the indicator confirms the lock.
  10. Enterprise admin can disable recording, sharing, or model training. Future P2 — admin controls in NodeBench settings; defaults are restrictive.

Master feature map #

Nineteen features across three layers: 8 ScratchNode Live P0s, 6 NodeBench AI P0s, 5 Sync pipes. Status reflects the 2026-05-27 reconciliation.

shipped 🟡 partial not yet
ScratchNode Live
8 P0 · public event room
  • Join room — anonymous, no app, no account
  • Public chat — Convex realtime + presence
  • /ask — public sourced agent answers
  • Answer card + trace — sources + reuse chip
  • Private note — owner-only, anchored
  • FAQ suggest / promote — guest suggests, host promotes
  • Wiki publish — host-gated version snapshot
  • Sign-in / my events — joined & hosted events
NodeBench AI
6 P0 · private workspace
  • Daily Brief — narrative + signals
  • Notebook — TipTap blocks + backlinks
  • Library / Artifacts — saved memos & reports
  • Private chat — owner-only agent thread
  • Search — Typesense across workspace
  • Trace — audit + telemetry surface
Sync Transfer
5 P0 · ScratchNode → NodeBench
  • Event artifact — PR #408 handoff packet
  • Private notes — synced on sign-in
  • 🟡Entities / mentions — partial wiring
  • 🟡Sources / claims — manual export only
  • Trace / usage — not yet wired across domains

MVP build order — Live Assist track #

Four-phase ladder. P0 covers what the prototype already proves; P1 introduces transcript awareness; P2 ships the meeting bot; P3 wires the handoff back to NodeBench.

P0
Private live note assist private mode ✅, anchors ✅, voice (impl pending), summary (partial)
80%
P1
Transcript-aware assist cue cards, decision extraction, /ask over transcript, entity tracking
0%
P2
Meeting bot integration Zoom / Meet / Teams, visible participant, host + admin settings
0%
P3
NodeBench handoff PR #408 shipped — joined events + private notes flow
100%

Production architecture #

The static file still has local fallback state, but scratchnode.live now boots a Convex-backed live runtime for chat, sourced answers, FAQ, wiki publishing, and private notes. Production splits responsibilities across four layers so the runtime is fast, cheap, and the durable layer stays trustworthy. The product message stays simple: "Ask once, answer many. Live context while the event is happening. Durable wiki after it ends. Private notes stay private."

The stack

arch                         ┌──────────────────────────┐
                         │      ScratchNode UI       │
                         │ public room / private note│
                         └─────────────┬────────────┘
                                       │
                                       ▼
                         ┌──────────────────────────┐
                         │  API / pi-ai runtime     │
                         │ context router + tools   │
                         └─────────────┬────────────┘
                                       │
      ┌────────────────────────────────┼────────────────────────────────┐
      ▼                                ▼                                ▼
┌─────────────┐              ┌──────────────────┐              ┌─────────────┐
│   Convex    │              │  Redis hot layer │              │  Typesense  │
│ source truth│              │ live/session AI  │              │ snappy search│
└──────┬──────┘              └────────┬─────────┘              └──────┬──────┘
       │                              │                               │
       ▼                              ▼                               ▼
event wiki                  presence / chat tail              @mention search
FAQ entries                 semantic answer cache             event/entity search
private notes               TTL session memory                public wiki search
artifacts                   active run state                  upload/source search
sources/traces              context retrieval packets         autocomplete
                                                                       │
                                       Linkup / external ──────────────┘
                                       only when corpus misses
                                       or freshness needs refresh

Responsibilities (do not blur these lines)

LayerOwnsDoes NOT own
ConvexDurable source of truth, permissions, event wiki versions, notebook blocks, private notes, tracesMillisecond semantic reuse
Redis hot layerLive room state, semantic answer cache, TTL memory, agent run state, context-retrieval packetsCanonical records
TypesenseFast human-facing search, autocomplete, filters, typo-tolerant lookupAgent memory or session state
Linkup / externalExternal source discovery and fetch when corpus missesRepeated FAQ answers
pi-ai runtimeOrchestration, streaming, skill/tool dispatchOwning data directly

Agent output L1/L2/L3 contract

Every agent output must now be classified, validated, stored, rendered, traced, and evaluated through one shared contract: L1 broad family, L2 object category, and L3 exact contract / renderer / validator. The executable registry lives in src/shared/agentOutputContract.ts; home-v5.html records evaluated envelopes during runDemoFull().

textpublic_knowledge / event_faq / faq.cached_reuse_answer
private_memory / live_cue / cue.question_suggestion
private_memory / private_note / note.anchored_to_chat
retrieval_context / index_search / retrieval.context_packet
operational_cache / semantic_answer_cache / cache.public_faq_answer
agent_trace / output_node / trace.output.public_answer
generated_artifact / meeting_brief / artifact.private_meeting_summary
generated_artifact / event_archive / artifact.published_event_wiki
✅
Acceptance gate: no output is accepted unless it has a valid l1/l2/l3, visibility boundary, target id, traceRef, source/citation arrays, producer metadata, storage policy, renderer mapping, and evaluator mapping. runDemoQA().contract shows the demo envelope pass/fail summary by L1 family.

Risk / attack evaluator

The adversarial evaluator wraps the output contract. Output correctness asks whether the agent produced a valid L1/L2/L3 object. Risk robustness asks whether a specific L1/L2/L3 attack triggered a specific L1/L2/L3 risk in the response, tools, retrieval context, cache, writes, trace, or UI state.

textOutput: public_knowledge / event_faq / faq.cached_reuse_answer
Risk:   privacy / public_private_boundary / risk.private_note_leaked_public_chat
Attack: prompt_attack / direct_instruction_override / attack.include_private_notes_public_ask
âš 
Adversarial gate: src/shared/riskAttackEvaluator.ts checks private-note leakage, normal-chat agent invocation, FAQ host gating, cache visibility collisions, wrong-event retrieval, private wiki compaction, and trace search honesty. runDemoQA().riskAttack exposes the demo summary; runRiskAttackQA() can be called directly from the browser console.

The /ask runtime pipeline

The agent answer trace shown in the UI is a flattened view of this pipeline. Every step in the trace corresponds to a real cache/retrieval decision.

  1. Resolve context — eventId, threadId, visibility, mentioned entities, private/public mode.
  2. Semantic cache lookup (LangCache pattern) — is this substantially the same as a known FAQ/answer? Hit → return cached, increment reuse. Miss/stale → continue.
  3. Context retriever — fetch event wiki, FAQ, chat tail, source bundles, backlinks, famous branches. Only governed tools, never raw DB access.
  4. pi-ai run — answer only with retrieved handles + allowed tools.
  5. Persist — Convex writes answer, sources, trace, optional FAQ candidate.
  6. Update hot layer — Redis semantic cache, run state, FAQ similarity index, room pulse.
The UI surfaces this as the cost-summary chip line on every .ans card: "Answered from event wiki · 12 similar · 4 reused · 0 new searches · 🔒 no private notes." When users see "0 new searches" they're literally seeing the cache hit.

Semantic-cache key shape

Every cache key MUST include the boundary fields below — otherwise a private answer could satisfy a public cache hit, or a stale wiki version could satisfy a fresh query.

tstype SemanticAnswerCacheEntry = {
  eventId: string;
  visibility: "event_public" | "private";
  questionEmbedding: number[];
  normalizedQuestion: string;
  answerId: string;
  faqEntryId?: string;
  wikiVersion: number;
  sourceBundleVersion: number;
  privateContextAllowed: boolean;
  freshness: "fresh" | "stale" | "needs_review";
  ttlSeconds: number;
  reuseCount: number;
  lastUsedAt: number;
};

// A cache HIT is valid only if all of these match:
const valid =
  entry.eventId === ctx.eventId &&
  entry.visibility === ctx.visibility &&
  entry.wikiVersion === ctx.wikiVersion &&
  entry.sourceBundleVersion === ctx.sourceBundleVersion &&
  entry.freshness !== "stale" &&
  similarity >= threshold;

TTL policy

DataTTLWhy
Event presenceminutesDrops when user leaves
Composer draftminutesRecover from tab refresh
Active run state1–24hSpan an event session
Chat hot tailevent duration + bufferFast scrollback during the event
Semantic answer cacheevent-defined, source-awareHit rate vs freshness tradeoff
Published wiki cachelong, invalidated on new versionPost-event the wiki is cheap to serve

MCP tool design (governed, not raw)

Agent tools receive context handles and citation handles, not arbitrary Redis keys or Convex queries. This is the Redis Context Retriever pattern applied to ScratchNode.

tsscratchnode.resolve_context({ text, activeEventId, visibility, mentionedUris });
scratchnode.semantic_cache_lookup({ normalizedQuestion, eventId, visibility, wikiVersion });
scratchnode.retrieve_context({
  eventId, query,
  scope: ["faq", "wiki", "chat", "sources", "backlinks", "famous_branches"],
  visibility
});
scratchnode.write_session_memory({ eventId, runId, ttl, payload });
scratchnode.promote_answer_candidate({ answerId, eventId, role: "guest" | "host" });
scratchnode.get_private_notes({ userId, eventId }); // PRIVATE MODE ONLY — gated by visibility check

What to cache vs not cache

Cache aggressivelyNever cache
Public FAQ answers
Public event wiki sections
Source bundle summaries
Entity profile snippets
Mention autocomplete
Event chat search windows
Famous graph branches
Private answers
Answers using private notes
Answers from stale sources
Answers with unresolved contradictions
Host-draft wiki sections
Deep-research-in-progress

Implementation phases

  1. Phase 1 — Convex + Typesense only. Event wiki, FAQ, chat, private notes, sources, traces in Convex. Search-as-you-type via Typesense. In-memory app cache for basic FAQ reuse.
  2. Phase 2 — Add Redis hot room layer. Presence, room chat tail, active /ask runs, Q&A queue, poll state.
  3. Phase 3 — Add semantic cache. Question normalization, embeddings, similarity threshold, context-bound cache key, TTL/freshness, strict public/private separation.
  4. Phase 4 — Add context retriever tools. Governed structured tools for FAQ, wiki, chat, backlinks, source bundles, private notes.
  5. Phase 5 — Add live mirror / CDC. Convex outbox → worker → Redis projection update → index update. Skip full RDI on day one.

Risk controls

RiskMitigation
Stale cache — semantic cache returns old answer confidentlyKey includes sourceBundleVersion + wikiVersion; stale hits trigger delta refresh; trace shows cache use; host can invalidate FAQ
Wrong event corpus — agent answers using wrong contextactiveEventId required; room code visible in event-identity strip; context resolver trace shown; ambiguous events ask confirmation
Private leak — private note enters public cachevisibility in every cache key; private answers never auto-promote; public traces explicitly say "No private notes used"; Convex permission filter before cache write
Client-side role bypass — user flips body[data-role="host"] in devtools and sees host UIServer-side enforcement is mandatory. See callout below.
🛡️
Role gating is CSS-only in this prototype. The patterns body[data-role="attendee"] / body[data-role="host"] hide the host actions visually, but a user can flip the attribute in devtools and reveal the buttons. Production MUST verify membership role server-side on every mutation — promoteAnswerToFAQ, publishWiki, hideMessage, approveRevision, and any other host capability — using the authenticated session claim, not the request body. Client-side gating is a UX hint, not a security boundary.
📐
Anti-recommendation: Do not replace Convex with Redis. Use Redis to make the runtime fast and cheap; let Convex stay the durable source of truth. Do not expose "Redis" as a product concept — expose the benefits (cache-hit %, searches avoided, fresh sources).

Two-domain deploy #

ScratchNode and NodeBench AI ship from one repo but live on two domains. The URL boundary matches the product boundary the v4/v5 prototypes already encode.

Domain responsibilities

DomainSurfaceAudienceAuth
scratchnode.livePublic event rooms, lightweight account state, private notes, host console, and published event wikis (v5 surface).Anonymous guests, signed-in attendees, and event hostsGuest session by default; optional ScratchNode magic link / OAuth for sync and hosting
nodebenchai.comPrivate workspace, notebooks, reports, artifacts, Daily Brief, graph context, billing, and deeper research flows (v4 + existing surfaces).Signed-in operators, founders, and teams continuing work after eventsWorkspace auth, sessions, and permissioned private memory
🔀
Anonymous-by-default on scratchnode.live. Guests never need to sign in to chat, /ask, or save device-scoped private notes. Signing in on ScratchNode unlocks My joined events, My hosted events, synced private notes, saved answers, and wiki publishing. Open in NodeBench is the explicit handoff for deeper report notebooks, artifacts, graph inspection, and private research.

Vercel setup — single project, host-based routing

One Vercel project serves both domains. vercel.json has host-keyed rewrites + www→apex redirects:

json{
  "redirects": [
    { "source": "/(.*)", "has": [{"type":"host", "value":"www.scratchnode.live"}], "destination": "https://scratchnode.live/$1", "permanent": true },
    { "source": "/(.*)", "has": [{"type":"host", "value":"www.nodebenchai.com"}], "destination": "https://nodebenchai.com/$1", "permanent": true }
  ],
  "rewrites": [
    // scratchnode.live: serve the v5 event-room shell for root, /e/:slug, /docs
    { "source": "/",           "has": [{"type":"host", "value":"scratchnode.live"}], "destination": "/proto/home-v5.html" },
    { "source": "/e/:slug*",  "has": [{"type":"host", "value":"scratchnode.live"}], "destination": "/proto/home-v5.html" },
    { "source": "/docs",       "has": [{"type":"host", "value":"scratchnode.live"}], "destination": "/proto/docs.html" },
    { "source": "/(.*)",       "has": [{"type":"host", "value":"scratchnode.live"}], "destination": "/proto/home-v5.html" },
    // nodebenchai.com: SPA catch-all (existing behavior)
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

First-time setup checklist

  1. Buy scratchnode.live — Cloudflare Registrar (at-cost, free WHOIS privacy, no upsells). Turn on auto-renew, 2FA, registrar lock.
  2. Point nameservers to Vercel — or use Cloudflare's DNS with proxy OFF for the apex (Vercel handles cert + edge). On Cloudflare: A record @76.76.21.21, CNAME wwwcname.vercel-dns.com.
  3. Add the domain to the existing Vercel project — Project Settings → Domains → Add scratchnode.live + www.scratchnode.live. Both auto-issue Let's Encrypt certs.
  4. Deploy — push to main. Vercel builds once and serves both hosts from the same artifacts.
  5. Verify with the live-DOM check — see Live verify below. Don't claim "deployed" until the raw HTML at https://scratchnode.live/ contains the ScratchNode OG tags, not NodeBench ones.

Live verify (don't trust git push alone)

bash# Should return ScratchNode-branded HTML
curl -sL https://scratchnode.live/ | grep -E '(og:site_name|canonical|AI Infra Summit)'

# Should redirect www → apex (HTTP 301)
curl -sI https://www.scratchnode.live/ | head -3

# /e/:slug must serve the v5 shell, not 404
curl -sI https://scratchnode.live/e/ai-infra-summit-2026 | head -3

# nodebenchai.com still serves the workspace SPA
curl -sL https://nodebenchai.com/ | grep -E '(VITE|root|index)'

Shared backend

LayerShape
ConvexSingle deployment. Both UIs query the same project. Permissions checks gate cross-product reads.
StripeOne account, two product lines (workspace_pro, event_host_pro). Webhooks go to nodebenchai.com/api/stripe.
AnalyticsOne Posthog/GA4 property. Stitch cross-domain sessions with linker: ['nodebenchai.com', 'scratchnode.live'].
EmailResend / Postmark. events@scratchnode.live for guest comms, hello@nodebenchai.com for host/account comms.
Convex envAdd PUBLIC_EVENT_BASE_URL=https://scratchnode.live and WORKSPACE_BASE_URL=https://nodebenchai.com so server-side share-URL generation matches client-side.

Deploy anti-patterns to avoid

Live prod plan: prototype → real #

⚡ TL;DR — where we are vs where we're going

Today (Phases 1-5 SHIPPED): scratchnode.live/e/<slug> is real Convex-backed multi-user chat with sourced /ask, FAQ suggestion, host claim, wiki publish, public source packets, answer cache, private notes persisted through Convex, and owner-only private annotations anchored to public messages. The current production floor is live enough to dogfood in a real small event room.

Goal: "Two strangers on different devices open the same room URL and chat with each other in real time, with sourced agent answers and a private notebook each." That core loop is now live through Convex; the remaining work is hardening quality, auth, rate limits, and provider-grade deep research.

Remaining work: Harden durable host authentication, add Redis/edge hot-room projection only after measuring Convex latency, and keep expanding browser QA. Provider-grade /ask now has a real action path with deterministic fallback; the next frontier is deeper research quality, not basic backend wiring.

Where we are today (honest baseline)

CapabilityTodayProduction target
Shared chat between visitorsConvex realtime query + presenceRate limits, moderation, and account upgrade path
/ask agent answersConvex action → Anthropic when configured, deterministic fallback otherwiseAdd richer eval sets, rate limits, and provider budgets
Sources / citation countsConvex source records + answer source chipsExpand provider/source ingest coverage
Private notesConvex per-session table with owner-key gating + anchor metadataScratchNode account sync + encrypted per-user store
Identity / display namelocalStorageAnon session UUID + optional auth upgrade
FAQ promote / wiki publishConvex mutations with host gateReviewer workflow, audit trail, and stronger policy UI
Sign-inGuest/session + host-code authScratchNode magic link for joined/hosted events and synced notes; NodeBench sign-in only for workspace continuation
Event wikiConvex-backed, version snapshottedScheduled compaction and richer source grouping
Semantic answer cacheExact normalized public-answer cacheRedis/LangCache semantic projection only after measuring need

Phased execution plan

Each phase is independently shippable — you can deploy after any phase and the product is better than the day before. Schema sketches use the existing Convex monorepo conventions (convex/schema.ts, convex/<domain>/queries.ts, etc).

Phase 0 Foundation (already done) complete

A real URL anyone can visit, branded, fast, on production-grade infra.

  • Domain scratchnode.live registered on Cloudflare
  • DNS: A @ → 76.76.21.21 (DNS-only) + CNAME www → cname.vercel-dns.com
  • Vercel: apex Production + www → 307 → apex
  • vercel.json host-keyed rewrites for /, /e/:slug*, /docs
  • ScratchNode-branded OG/Twitter cards, canonical, theme-color
  • Cross-domain auth-redirect stubs (host/sign-in → workspace)
  • SSL via Let's Encrypt, www→apex 301, www.nodebenchai.com→apex 301
Phase 1 Anonymous shared chat — the first real feature ~2–3 days

Two strangers opening scratchnode.live/e/<slug> see each other's messages in real time. Anonymous, no signup.

Convex schema additions (convex/schema.ts)

tsexport default defineSchema({
  events: defineTable({
    slug: v.string(),               // "ai-infra-summit-2026"
    name: v.string(),               // "AI Infra Summit"
    roomCode: v.string(),           // "ORBITAL"
    hostUserId: v.id("users"),
    status: v.union(v.literal("draft"), v.literal("live"), v.literal("ended")),
    startedAt: v.number(),
    endedAt: v.optional(v.number())
  }).index("by_slug", ["slug"]).index("by_roomCode", ["roomCode"]),

  eventMembers: defineTable({
    eventId: v.id("events"),
    sessionId: v.string(),          // anonymous browser UUID
    displayName: v.string(),
    joinedAt: v.number(),
    lastSeenAt: v.number()          // TTL for presence; janitor cron evicts >5min
  }).index("by_event_session", ["eventId", "sessionId"])
    .index("by_event_lastSeen", ["eventId", "lastSeenAt"]),

  eventMessages: defineTable({
    eventId: v.id("events"),
    sessionId: v.string(),          // who sent it (anon)
    displayName: v.string(),        // snapshot at send time
    text: v.string(),
    kind: v.union(v.literal("chat"), v.literal("ask"), v.literal("system")),
    replyToMessageId: v.optional(v.id("eventMessages")),
    createdAt: v.number()
  }).index("by_event_time", ["eventId", "createdAt"])
});

Convex functions to write

  • convex/events/queries.tsgetEventBySlug(slug), getMessages(eventId, limit, beforeCursor), getMembers(eventId)
  • convex/events/mutations.tsjoinEvent({slug, sessionId, displayName}), sendMessage({eventId, sessionId, text, kind, replyToMessageId?}), heartbeat({eventId, sessionId})
  • convex/events/crons.ts — janitor that evicts eventMembers with lastSeenAt < now - 5min
  • Seed mutation for the demo event ai-infra-summit-2026 with code ORBITAL

UI rewiring (public/proto/home-v5.html)

The good news: sendComposerMessage() is already the single entry point. Wire the three branches to Convex.

  • Add <script type="module"> that imports ConvexClient from a CDN ESM build (or build a tiny bundled file at /public/proto/_convex.js)
  • Generate or recover anonymous session UUID via localStorage.getItem('sn_session_id') or crypto.randomUUID()
  • On page load: call joinEvent({slug, sessionId, displayName}) where slug = URL path's /e/<slug>
  • Subscribe to getMessages via Convex realtime — append/dedupe rows in #feed as they stream in
  • Replace postPublicChat() + postPublicAsk() DOM-append with sendMessage mutation (kind=chat or ask)
  • Add 30-second setInterval for heartbeat to keep presence alive
  • Wire onbeforeunload to send a final heartbeat with stale-flag (optional)

Open https://scratchnode.live/e/ai-infra-summit-2026 in Chrome and Safari (or two different browsers). Type "hi from chrome" in one; within 1s the other should show that message. Event title, room code, member count, FAQ count, people, and unpublished-wiki state now hydrate from the live room instead of stale static counts.

Convex action budget on free tier (1M function calls/month). Heartbeat at 30s × 100 users × 1 event = 3M/month — so heartbeats must be cheap (return early if state unchanged), or move presence to a Redis-style hot layer later (Phase 6).

Phase 2 Real /ask agent backed by sources ~3–5 days

When a guest types /ask <question>, the agent returns a real, source-cited answer drafted from the event's corpus (uploaded sources + chat history + prior /ask answers) — not a hardcoded card.

Schema additions

tseventSources: defineTable({
  eventId: v.id("events"),
  uri: v.string(),                  // canonical source URI
  kind: v.union(v.literal("transcript"), v.literal("doc"), v.literal("url"), v.literal("slide")),
  title: v.string(),
  bodyEmbedding: v.array(v.number()), // vector for semantic search
  excerpt: v.string(),
  uploadedAt: v.number()
}).vectorIndex("by_embedding", { vectorField: "bodyEmbedding", dimensions: 1536, filterFields: ["eventId"] }),

eventAnswers: defineTable({
  eventId: v.id("events"),
  questionMessageId: v.id("eventMessages"),
  body: v.string(),
  sourceIds: v.array(v.id("eventSources")),
  trace: v.array(v.object({
    step: v.string(),               // "cache_lookup", "retrieve", "llm_run", ...
    status: v.union(v.literal("ok"), v.literal("miss"), v.literal("error")),
    detail: v.optional(v.string()),
    durationMs: v.number()
  })),
  cacheHit: v.boolean(),
  faqStatus: v.union(v.literal("none"), v.literal("suggested"), v.literal("promoted")),
  createdAt: v.number()
}).index("by_event_time", ["eventId", "createdAt"])

Convex action + UI wiring

  • convex/events.tsaskAgent({eventId, question, sessionId}): bounded public-source retrieval, prior eventAnswers cache lookup, optional Linkup freshness probe, Anthropic provider call when configured, deterministic fallback, quality gate, and persisted {answerId, body, sourceIds, trace}
  • Replace streamAgentAnswer's setTimeout with: client.action('events:askAgent', {eventId, question, sessionId}), falling back to events:composeAnswer only if actions are unavailable
  • Render result.sources as the source chips (with real URLs from eventSources)
  • Render result.trace, model/cost metadata, live-search count, and deterministic QA score as the canonical agent trace
  • Replace the hardcoded reuse chip ("Answered from event wiki · 12 similar") with counts from result
  • Wire "Suggest for FAQ" / "Promote to FAQ" buttons to a server mutation that updates eventAnswers.faqStatus (gated by role in Phase 4)

Source seeding

  • Bulk upload script: convex/events/seedSources.ts — accepts a JSON file of {title, body, uri} per source, embeds via OpenAI text-embedding-3-small, inserts into eventSources
  • Seed the demo event with ~5 real sources (Anthropic blog, MCP spec, panel transcripts) so the agent has something to cite

/ask "What is the MCP auth timeline?" — answer comes back in <5s, body is non-canned text grounded in the seeded sources, source chips link to real URLs, trace shows real durations. Same question 30s later: cache hit, <500ms, trace says "Matched 1 similar in semantic cache".

Anthropic API key in Convex env (ANTHROPIC_API_KEY) - required for provider generation. If it is missing or the provider fails, the same public-source action falls back to deterministic synthesis. Cost: track per answer and cap by event budget.

Phase 3 Private notes persistence ~1–2 days

Private notes survive page reload and follow the anonymous session across devices if the user signs in. They never enter the public feed or the wiki (release-blocker invariant — already enforced client-side, now enforced server-side too).

Schema

tsuserNotes: defineTable({
  ownerKey: v.string(),             // sessionId OR userId:<id> once signed in
  eventId: v.optional(v.id("events")),  // scoped to event, or null for general
  title: v.string(),
  bodyHtml: v.string(),             // rich text (TipTap output in Phase 7)
  tags: v.array(v.string()),
  pinned: v.boolean(),
  isAsk: v.boolean(),               // was created via private /ask
  createdAt: v.number(),
  updatedAt: v.number()
}).index("by_owner_event", ["ownerKey", "eventId"])
  .index("by_owner_updated", ["ownerKey", "updatedAt"])

Tasks

  • convex/notes/mutations.tscreateNote, updateNote, pinNote, deleteNote (all gated by ownerKey === ctx.session.ownerKey)
  • convex/notes/queries.tslistMyNotes(eventId?) returns only notes where ownerKey === currentOwnerKey
  • Replace window._notes_v5 read paths with Convex query subscription
  • Replace createNewNote / updateActiveNote / togglePinNote / deleteActiveNote with the corresponding mutations
  • Keep localStorage as offline-first cache; on Convex confirm, mark synced
  • Server-side test: try to read someone else's notes by spoofing ownerKey → must return 403

Open notes sheet, add 3 notes, refresh page → all 3 still there. Open in incognito with a new session ID → empty (notes are session-scoped). Sign in (Phase 4) → previous-session notes migrate to your userId.

Anonymous session IDs are device-scoped — clearing browser data loses notes. Mitigation: prompt to "save your notebook" via magic-link sign-in once user has 3+ notes. Critical: validate ownerKey server-side; client-controlled ownerKey means anyone could read anyone else's notes.

Phase 4 Host actions + cross-domain auth ~2–3 days

Hosts can create events, moderate, promote FAQ candidates, end events, and publish wikis. Server enforces host role on every privileged mutation — the CSS gating in the prototype is reinforced with real authorization.

Schema + auth flow

tseventHosts: defineTable({
  eventId: v.id("events"),
  userId: v.id("users"),
  role: v.union(v.literal("owner"), v.literal("moderator")),
  addedAt: v.number()
}).index("by_event_user", ["eventId", "userId"])
  • scratchnode.live/sign-in?return=<url> route — magic link auth, on success redirect back to return URL with a signed short-lived ScratchNode session in URL hash
  • scratchnode.live reads the session token from hash, exchanges it for a Convex session via auth/exchangeScratchNodeSession action, sets a same-site cookie scoped to .scratchnode.live
  • convex/events/mutations.ts — add server-side guard requireHostRole(ctx, eventId) that throws if !eventHosts.exists({eventId, userId: ctx.userId})
  • Apply guard to: promoteToFAQ, publishWiki, hideMessage, kickMember, endEvent
  • UI: real host console replaces the prototype host sheet — list of FAQ candidates, hidden messages, member list with kick action
  • Test: while signed in as attendee, devtools-flip body[data-role="host"] + click Promote → server returns 403 + UI shows error toast

Sign in on nodebenchai.com → click "Create event" → redirected to scratchnode.live/e/<new-slug> as the host. Attendee opens same URL → sees Suggest button. Host opens → sees Promote button + host console. Server-side authorization on Promote returns 403 for attendee.

JWT-in-hash means it's exposed to browser history. Mitigation: short TTL (60s), rotate on exchange, one-time-use. Also: don't accept the JWT if Referer is unexpected.

Phase 5 Event wiki + compaction ~3–4 days

When the host ends the event, the room compacts into a publicly-shareable wiki — sourced answers, what changed, top Q&A, people, sources. Live link survives indefinitely. Search engines can index it.

Schema

tseventWikiVersions: defineTable({
  eventId: v.id("events"),
  version: v.number(),              // monotonic; latest is current
  publishedBy: v.id("users"),
  publishedAt: v.number(),
  status: v.union(v.literal("draft"), v.literal("published")),
  sections: v.array(v.object({
    id: v.string(),                 // "overview", "need-to-know", "qa", "sources"
    title: v.string(),
    bodyMarkdown: v.string(),       // rendered to HTML server-side for SEO
    sourceIds: v.array(v.id("eventSources"))
  })),
  counts: v.object({
    members: v.number(), faqEntries: v.number(),
    sources: v.number(), questions: v.number()
  })
}).index("by_event_version", ["eventId", "version"])

Tasks

  • convex/wiki/actions.tscompactEventWiki({eventId}): pull all promoted FAQ entries, source list, member count; generate sections via a templated Anthropic call; insert as new eventWikiVersion with status=draft
  • publishWikiVersion({wikiVersionId}) mutation — flips draft→published; sets events.status=ended
  • UI: the existing wiki sheet renders from the latest published version (replacing hardcoded HTML)
  • SEO: server-render the wiki at scratchnode.live/e/<slug>/wiki on Vercel edge so crawlers get the real content (not the SPA shell)
  • Add Open Graph image generation via existing /api/og/[id].tsx route — dynamic OG card per event (title, room code, counts)
  • sitemap.xml at scratchnode.live/sitemap.xml — lists all published events

Host clicks "End event + publish wiki" → wiki sheet flips from prototype hardcoded content to compacted content from the actual room. Public URL scratchnode.live/e/<slug>/wiki is crawlable (raw HTML contains the wiki body — not just an SPA shell). Twitter/Slack link previews show event title + room code + counts.

Anthropic compaction call could fabricate or omit content. Mitigation: deterministic structural fields (counts, member list, source list) computed from Convex; only the prose summary uses the LLM, and each prose claim must cite a sourceId.

Phase 6 Redis hot layer + semantic cache ~1–2 weeks

Make /ask cheap and fast at scale. Same question asked by different attendees returns in <500ms with "0 new searches" because the answer is cached at the semantic-similarity level. Live room state (presence, chat tail, active runs) moves off Convex into Redis for lower latency + lower Convex usage.

Refer to the Production architecture section for the full Redis Iris–style design. This phase implements it concretely.

Tasks

  • Stand up Redis (Upstash for serverless-friendly TLS, or Redis Cloud) — single instance is fine for v1
  • scripts/redis-client.ts — wrapper with auth + JSON-friendly get/set/ttl
  • Semantic cache layer: convex/cache/semanticCache.tslookup(question, eventId, visibility, wikiVersion) returns cached answer if similarity ≥ 0.92 AND sourceBundleVersion matches
  • Modify askAgent action: call semanticCache.lookup first; on hit, return cached entry + trace marks cacheHit=true
  • Move presence (eventMembers.lastSeenAt) to Redis sorted-set with 5-min TTL — Convex query reads from Redis for live counts
  • Move chat tail (last N=50 messages per event) to Redis list — Convex stays source of truth, Redis serves the hot read path
  • Add cache-hit rate to host console: "82% of /asks answered from cache · $X.XX saved this event"

Run a load test: 100 simulated guests open the room, each types /ask within 60s, half ask the same question. P95 latency for the duplicates should be <500ms with cacheHit=true. Convex function call count for /ask should be ~50% lower than without the cache.

Cache key MUST include visibility — a private /ask answer using private notes must never satisfy a public cache lookup. Test: send private /ask with note-derived data, then send same public /ask from another session → MUST return a fresh non-cached answer.

Phase 7+ Polish + growth (ongoing) parallel to all of the above

Production-grade UX, observability, and growth loops on top of the working product.

  • TipTap/ProseMirror replaces execCommand for the notes editor (rich text + mentions + backlinks model)
  • Client-side QR code generation (replace external api.qrserver.com with a tiny SVG generator inlined into v5)
  • Real magic-link auth via Resend/Postmark on nodebenchai.com
  • Analytics (PostHog/GA4) with cross-domain linker for nodebenchai.com ↔ scratchnode.live
  • Slack/Discord notification on event start (for hosts who opt in)
  • Mobile composer to bottom on <540px (chat-app convention; currently sticky top)
  • Voice /ask via existing voice WebSocket (already wired into NodeBench workspace — extend to event surface)
  • Per-event branding (custom colors, logo) for paid hosts
  • Stripe billing: free events up to 100 attendees, event_host_pro tier for unlimited + custom branding
  • Replay link: events that ended can be replayed at original timestamps via a scrubber

Cumulative timeline

After phaseCumulative timeWhat's live
00 dStatic prototype at scratchnode.live (today)
1~3 dReal multi-user chat. Strangers see each other's messages.
2~7 dReal /ask with sourced answers grounded in event corpus.
3~9 dNotes persist across reload. Minimum viable "real" product.
4~12 dHosts can create events, moderate, publish; server-enforced auth.
5~16 dEvent wiki is real. SEO-indexable. Shareable forever.
6~28 dProduction-grade latency + cost. Cache hit rate >70%.

Go/no-go launch criteria

Do not announce scratchnode.live as a finished self-serve platform until host auth, abuse controls, provider-backed deep research, and event creation are hardened. It is now safe for controlled dogfood: small event rooms, founder demos, and QA sessions where the live Convex loop is the point being tested.

Soft-launch milestones (sharing privately):

Concepts & metaphor #

Think Mentimeter × Jackbox × Notion: a room code, a live shared chat, an /ask agent backed by sources, private notes that you can promote into the public conversation, and a clean wiki published when the event ends.

Quick start #

Open home-v5.html directly in a browser. No build, no npm. To skip onboarding and land mid-conversation:

url/demo_ver1?demoSpeed=instant

To replay onboarding from a specific step:

urlhome-v5.html#wiz=2
Tip: All demo controls are also exposed as functions on window — see JS API.

v5 at a glance #

The viral-SaaS simplification: one room, one feed, one notebook. Mobile-first. Single-file HTML, ~2000 lines. Brutal feature reduction from v4.

SurfacePurposeHow to open
Hero + composerType to chat the roomDefault landing
Wiki sheetSDK-style live wikiopenSheet('wiki')
Notes sheetApple+Mem editoropenSheet('notes')
People sheetRoster + presenceopenSheet('people')
Share sheetQR + platformsopenSheet('share')
Host sheetCreate your own eventopenSheet('host')
Sign-in sheetMagic-link authopenSheet('signin')

Composer & modes #

The composer is the only persistent input. It supports four input affordances:

Mode toggle

The lock icon switches between public (default) and private. Mode lives on document.body[data-mode]. CSS keys off this for color shifts.

@mention picker

Typing @ opens a floating picker positioned at the caret. Arrow keys navigate, Enter inserts. Inserted mentions render as purple chips. web picker stays open while typing. mob picker docks above keyboard.

Reply & react

Hover (or long-press on mobile) any row → action icons appear at the bottom-right:

When a row has reactions, the hover actions automatically lift above the reactions row.

css/* Actions snap to bottom-right; lift above reactions when present */
.row-actions { position: absolute; right: 8px; bottom: 4px; }
.row:has(.row-reactions) .row-actions { bottom: 30px; }

Private notes

Lock icon → purple → composer switches to private mode. Anything you send while purple:

  1. Never enters the public feed (release-blocker invariant).
  2. Routes to the rich notes store (window._notes_v5).
  3. Triggers a centered toast: "🔒 Private note saved — View all notes →".
  4. Increments the 🔒 N notes badge in the identity row.
🛑
Privacy invariant: The data path matches the promise. The composer says "only your notebook." A private send returns before any #feed insert.

Voice input

🎤 button uses the Web Speech API (webkitSpeechRecognition). Transcribes into the composer in real time. Graceful fallback: tooltip "Voice not supported" if the API is missing.

Universal sheets #

All feature surfaces use a single sheet system. mob slides up from bottom. web centers as a modal. Driven by:

jsvar SHEET_RENDERERS = {
  name:    function() { ... },
  about:   function() { ... },
  share:   function() { ... },
  notes:   function() { ... },
  wiki:    function() { ... },
  people:  function() { ... },
  signin:  function() { ... },
  host:    function() { ... }
};
openSheet('wiki'); // renders, opens scrim, sets data-sheet-type

Wiki — SDK-page treatment

The wiki sheet upgrades to a 3-column SDK-style docs layout when the viewport is ≥ 720px:

Below 720px, the layout collapses to a single-column readable article (TOC and on-page rails hidden).

Selectors

ClassRole
.wiki-shell3-column CSS grid
.wiki-tocSticky left rail with .wiki-toc-links
.wiki-articleMain typography column
.wiki-calloutNotion-style purple callout
.wiki-src-chipsSource-citation chip row
.wiki-onpageRight-rail jump list

Wiring

jswireWikiBehaviors(); // called automatically by openSheet('wiki')
// Sets up: smooth-scroll on TOC click, IO-based scroll-spy, search filter

Notes — Apple + Mem.ai editor

Two-panel layout: list (left, 260px) + editor (right). Mobile collapses to single pane with a "← Notes" back button.

Features

Note schema

ts{
  id: "n_1748212398_x7k",
  title: "AI Infra Summit takeaways",
  body: "<p>Three signals worth tracking...</p>", // rich HTML
  tags: ["orbital", "mcp"],
  pinned: true,
  isAsk: false,
  createdAt: 1748212398000,
  updatedAt: 1748212398000
}

Keyboard shortcuts (notes sheet)

KeyAction
+NNew note
+PPin / unpin active note
+Shift+KFocus search
+B / I / UBold / italic / underline (native)

Share sheet

Copy URL, native share (navigator.share), or platform-specific intents (Twitter / LinkedIn / Email). Also displays a QR code for in-room scan-to-join.

People sheet

Roster of attendees with name, role, online status. Tap a row to open their @-mentions and questions (toast in current proto).

Host / create event

Launch surface supports creating a real Convex-backed room, claiming host on an existing room, rotating a host claim code, reviewing FAQ suggestions, managing host-owned sources, ending events, and publishing the wiki. createEvent creates the event, joins the creator, issues a server-signed host token, and inserts a starter public source so /ask has honest context without demo fallback.

Landing & onboarding #

First-visit IIFE (detectFirstVisit) routes new users to the landing surface and persists their last view mode. Onboarding tour: 3 pulse-positioned steps over the composer and identity row.

Storage keys

Feed & agent threads #

The feed is a single column of .row elements with data-mid identifiers. Agent responses to /ask render as .ans children nested visually under the asking row:

css.ans {
  margin-left: 56px; /* indent under question */
  max-width: 560px;
  position: relative;
}
.ans::before { /* L-hook tree connector */
  content: '';
  position: absolute; left: -28px; top: 0;
  width: 12px; height: 18px;
  border-left: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  border-bottom-left-radius: 6px;
}

A MutationObserver watches #feed and auto-decorates new rows with hover actions and mention rendering.

v4 at a glance #

home-v4.html is the comprehensive spec proof. Four view modes, all the affordances v5 simplified away. ~9300 lines.

View modes #

v4 switches between four orthogonal surfaces via setViewMode(mode) and the URL fragment #viewmode=<mode>.

ModeAudienceTrigger
landingMarketing / first-touchcold start, no localStorage
workspaceReturning power usersetViewMode('workspace')
public_eventAttendees in a live roomroom URL or setViewMode('public_event')
host_consoleEvent host live opssetViewMode('host_console')

Notebook · Library · Chat · Stage #

Inside workspace, v4 exposes four tabs:

Graph snapshots & branches #

v4 builds an entity graph from chat content. Each version of the graph is a snapshot; users can branch at any point and famous-branches surface the most-traversed cuts. Reverse sync writes branch state back to the source text.

Attention walk #

Animates the host or a guest's path through the graph during a panel — a literal cursor that walks node → node so remote viewers can follow the line of reasoning.

Mobile vs Web — behavior matrix #

SurfaceMobile (<720px)Web (≥720px)
SheetSlides up from bottom, full-widthCentered modal, capped width
Wiki TOC + on-pageHidden — readable article only3-column SDK layout
Notes paneSingle-pane; tap row → editor; ← backTwo-pane (list 260px + editor)
Hover actionsLong-press → bottom-sheet menuHover reveal, bottom-right
Mention pickerDocks above keyboardFloats at caret
Onboarding tourPulse-positioned, verticalPulse-positioned, with arrows
Keyboard shortcut overlayHidden (no keyboard)? opens modal
Hapticsnavigator.vibrate on every actionNo-op
Voice buttonNative push-to-talk feelClick-to-record
Sharenavigator.share opens native sheetPlatform tiles open new tabs

Tap targets & safe area

All interactive elements are minimum 44×44px on mobile. Header/footer pad by env(safe-area-inset-*) so the iPhone notch and home indicator don't overlap UI.

css:root {
  --safe-top: env(safe-area-inset-top);
  --safe-bot: env(safe-area-inset-bottom);
  --safe-right: env(safe-area-inset-right);
  --safe-left: env(safe-area-inset-left);
}
.h { padding-top: calc(10px + var(--safe-top)); }

Bottom sheets vs modals

A single sheet element with data-open + data-sheet-type attributes. CSS swaps the transform target based on viewport width.

css.sheet { transform: translateY(100%); } /* mobile: slides up */
.sheet[data-open="true"] { transform: translateY(0); }
@media (min-width: 720px) {
  .sheet { transform: translate(-50%, 100%); } /* web: centered */
  .sheet[data-open="true"] { transform: translate(-50%, 50%); }
}

Haptics

Wrapped in a haptic(ms = 6) helper. No-ops on unsupported platforms (most desktop browsers). Calibrations:

Demo controls #

Every demoable state is reachable from either a URL hash or a window-scoped function. Use URL hashes when driving headless screenshots (Chromium can't always exec JS); use functions when you're hand-driving a live demo.

URL hashes

HashEffect
/demo_ver1Triggers runDemoFull() — full live timeline of incoming messages, /ask answers, mentions, reactions. Stale ?demo=1 and #demo links are ignored on live routes.
#wiz=1 / 2 / 3Jump directly to onboarding step N.
#emptyClears the feed and shows the empty-state CTA.
#viewmode=workspace(v4) sets view mode without going through landing.
?debug=1Exposes window._debug with internal state inspectors.

JS API (sim*)

These are global helpers safe to call from the console:

jssimPost("Alex Chen", "Series A closed today. Source-backed eval ships v3 next month.");
simAsk("What is the MCP auth timeline?");
simPrivate("Followup: ping Sarah about DeepMind tooling. #orbital");
simJoin("Maya from VoiceLayer");
simOpenSheet("wiki"); // or: notes, people, share, host, signin, about, name
simRun("demo-full"); // alias for runDemo()

Keyboard shortcuts #

Press ? at any time on web to open the cheatsheet modal.

KeyWhereAction
?Anywhere (no input focused)Open shortcut overlay
EscapeSheet / overlay openClose
+SAnywhereOpen share sheet
+KAnywhereFocus composer
/ Mention picker openNavigate
EnterMention picker openInsert selected
+NNotes sheetNew note
+PNotes sheetPin / unpin

Design tokens #

css:root {
  --bg:        #161513;  /* darkest layer */
  --paper:     #1c1b18;  /* card / sheet bg */
  --accent:    #d97757;  /* terracotta — primary CTA + branding */
  --purple:    #a78bfa;  /* private / personal data */
  --green:     #5ea867;  /* success / saved */
  --ink:       #e8e4dc;  /* primary text */
  --ink-muted: #b3ac9f;  /* secondary text */
  --ink-faint: #7a7368;  /* tertiary / hint */
  --line:      #2a2823;  /* dividers / borders */
  --mono: 'JetBrains Mono', ui-monospace, monospace;
  --ui:   'Manrope', system-ui, sans-serif;
  --r:    12px; /* card radius */
  --r-sm: 8px;  /* control radius */
}

Color semantics

localStorage keys #

KeyValuePurpose
sn_v5_seen'1'Returning-user flag
sn_v5_namestringDisplay name
sn_v5_first_visit_done'1'Skip first-visit landing
sn_v5_last_view_modeview nameRestored on return
sn_v5_host_coached'1'Host-tour seen
sn_v5_lock_tip'1'"What does lock do?" tip dismissed
sn_v5_tour_done'1'Onboarding completion
🧹
To reset the experience: localStorage.clear() then reload — you'll get the cold-start landing.

Privacy invariants #

These are non-negotiable. Every send-related code path is audited against them.

  1. Private mode never writes to the public feed. send short-circuits before #feed insert when data-mode === 'private'.
  2. Private notes never feed the public wiki. The wiki compaction reads only public messages, /ask answers, and host-uploaded sources.
  3. UI promise matches data path. The composer reads "only your notebook" while purple — the code path matches that string.
  4. Mentions ≠ notifications. Inserting @Alex renders a pill but does not call a notification endpoint in v5.
  5. v4 stays local; v5 is live. home-v4.html stores only browser-local prototype data. home-v5.html writes public room data and private owner-key-scoped notes to Convex when loaded through scratchnode.live.

What's next #

The two prototypes are deliberately parallel: v4 proves the full feature surface; v5 proves you can ship 10% of it and still earn the room. The docs page you're reading is the canonical reference between them.

Open v5 at /demo_ver1 to see all of it animated end-to-end. Open v4 to see the spec proof at full fidelity.