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.
- 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 privateuserNotesrow. - Normal chat never invokes the agent. Only
/askor@ScratchNodedispatches topostPublicAsk. - Agent answers always show their parent ask. Every
.anscard renders "replying to <name>'s /ask" in the head. - 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. - 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.
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.
| Feature | Status | Now | Production target |
|---|---|---|---|
| Identity / display name | Prototype | localStorage sn_v5_name | Guest session + optional account with magic link |
| Sign-in / magic link | Prototype | sendMagicLink() just toasts | Real OTP email + verified session token |
| Private notes | Production floor | Convex userNotes scoped by owner key, with local fallback only when backend is unavailable | Per-user encrypted store, syncs across devices when signed in |
| Private note annotations | Production floor | Owner-only markers anchored to public messages via anchorType / anchorId | Same privacy model plus time-range, answer, entity, and notebook-block anchors |
| Notes editor | Prototype | document.execCommand rich-text | TipTap / ProseMirror with mentions, backlinks, graph anchors |
| QR code generation | Prototype | External api.qrserver.com image | Client-side SVG generator — privacy, offline, branding |
sim* demo helpers | Prototype | Global functions on window | Hidden behind ?debug=1 or removed entirely in prod build |
| Reactions / replies | Prototype | DOM-only, lost on reload | Persisted server-side, broadcast via websocket |
| Event identity strip | Production-ready | Sticky line under header w/ live counts | Same shape, counts from event metadata API |
| Public/Private composer state | Production-ready | data-mode + visible badge | Same shape — keep the visible text label |
| Role-gated FAQ promotion | Production-ready | data-role="attendee" / "host" | Same shape — role from auth claim, not body attribute |
| Consolidated send pipeline | Production-ready | Single sendComposerMessage() entry | Same shape — sub-functions become server RPC calls |
| Accessible mention chips | Production-ready | <button class="mention"> + delegated handler | Same shape |
| Cue rail UI (Live Assist) | Prototype | Prototype-only on home-v5; Live Assist agent in flight | Persistent rail driven by Convex action; budget-aware cue cards |
| Cue generator (Convex action) | Prototype | Not yet built | Transcript-aware Convex action with rate + cost budget |
| Meeting mode toggle | Prototype | Prototype-only on home-v5; Live Assist agent in flight | Per-session mode selector (manual / user-capture / room-bot), surfaced in composer + header |
| 3 capture levels selector | Prototype | Prototype-only on home-v5 | Visible indicator + consent gate before level changes; persisted per event |
/ask variants (public / private / team) | Partial | Backend has composeAnswer + askAgent for public; no private/team variants yet | 3 variants: public (no private), private (owner notes only), team (member-scoped) |
| Transcript ingestion (Level 1+) | Prototype | Not yet built | User-side capture with visible recording indicator + per-event consent record |
| Meeting bot (Level 2) | Prototype | Not yet built | Zoom/Meet/Teams bot joins as visible participant; host opt-in required |
| Decision / action extractor | Prototype | Not yet built | Convex action over transcript spans; emits Decision Trace + Follow-up Builder items |
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.
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.
Manual assist
- How it works
- No audio capture. User types notes. Agent enhances private notes.
/askuses manually selected context. - Best for
- Sensitive calls, regulated industries, high-stakes meetings.
- Privacy
- Most private — nothing recorded.
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.
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.
- 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.
| Path | Runtime behavior | Cost posture |
|---|---|---|
| Normal chat | Public row only; no agent call; no Linkup search. | Near-zero marginal cost. |
| Public /ask | One room-visible sourced answer; eligible for FAQ/wiki/cache. | Shared once, reused by everyone. |
| Private Live Assist | Owner-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.
- No stealth mode. Every capture state is visible to the user — recording / transcribing / enhancing indicators are persistent and unmistakable.
- User-visible capture state. Recording, transcribing, and enhancing each show a distinct visible indicator. Indicator visibility is non-dismissible while active.
- Public/team vs private mode always visible. Composer placeholder updates by mode; rail badge mirrors the active mode at all times.
- Private cues never auto-post. Users decide whether to use each suggestion. Cues at most prefill a draft, never send.
- Private notes never enter shared transcript/wiki. Hard invariant enforced by
convex/events.ts:requireMember+publishWikiexclusion path. - Public/team answers say whether private context was excluded. Every public trace ends with "No private notes used · public layer only".
- Meeting organizer controls shared bot mode. Level 2 requires host opt-in. No room bot ever joins without host approval.
- Every generated note keeps original transcript/span anchor. Backward traceability — owner can verify what the agent enhanced versus what they wrote.
- Sensitive calls can use manual-only mode. Level 0 disables all audio + transcript capture; the indicator confirms the lock.
- 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.
- 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
- 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
- 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.
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)
| Layer | Owns | Does NOT own |
|---|---|---|
| Convex | Durable source of truth, permissions, event wiki versions, notebook blocks, private notes, traces | Millisecond semantic reuse |
| Redis hot layer | Live room state, semantic answer cache, TTL memory, agent run state, context-retrieval packets | Canonical records |
| Typesense | Fast human-facing search, autocomplete, filters, typo-tolerant lookup | Agent memory or session state |
| Linkup / external | External source discovery and fetch when corpus misses | Repeated FAQ answers |
| pi-ai runtime | Orchestration, streaming, skill/tool dispatch | Owning 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
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
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.
- Resolve context — eventId, threadId, visibility, mentioned entities, private/public mode.
- Semantic cache lookup (LangCache pattern) — is this substantially the same as a known FAQ/answer? Hit → return cached, increment reuse. Miss/stale → continue.
- Context retriever — fetch event wiki, FAQ, chat tail, source bundles, backlinks, famous branches. Only governed tools, never raw DB access.
- pi-ai run — answer only with retrieved handles + allowed tools.
- Persist — Convex writes answer, sources, trace, optional FAQ candidate.
- Update hot layer — Redis semantic cache, run state, FAQ similarity index, room pulse.
.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
| Data | TTL | Why |
|---|---|---|
| Event presence | minutes | Drops when user leaves |
| Composer draft | minutes | Recover from tab refresh |
| Active run state | 1–24h | Span an event session |
| Chat hot tail | event duration + buffer | Fast scrollback during the event |
| Semantic answer cache | event-defined, source-aware | Hit rate vs freshness tradeoff |
| Published wiki cache | long, invalidated on new version | Post-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 aggressively | Never 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
- 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.
- Phase 2 — Add Redis hot room layer. Presence, room chat tail, active
/askruns, Q&A queue, poll state. - Phase 3 — Add semantic cache. Question normalization, embeddings, similarity threshold, context-bound cache key, TTL/freshness, strict public/private separation.
- Phase 4 — Add context retriever tools. Governed structured tools for FAQ, wiki, chat, backlinks, source bundles, private notes.
- Phase 5 — Add live mirror / CDC. Convex outbox → worker → Redis projection update → index update. Skip full RDI on day one.
Risk controls
| Risk | Mitigation |
|---|---|
| Stale cache — semantic cache returns old answer confidently | Key includes sourceBundleVersion + wikiVersion; stale hits trigger delta refresh; trace shows cache use; host can invalidate FAQ |
| Wrong event corpus — agent answers using wrong context | activeEventId required; room code visible in event-identity strip; context resolver trace shown; ambiguous events ask confirmation |
| Private leak — private note enters public cache | visibility 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 UI | Server-side enforcement is mandatory. See callout below. |
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.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
| Domain | Surface | Audience | Auth |
|---|---|---|---|
| scratchnode.live | Public event rooms, lightweight account state, private notes, host console, and published event wikis (v5 surface). | Anonymous guests, signed-in attendees, and event hosts | Guest session by default; optional ScratchNode magic link / OAuth for sync and hosting |
| nodebenchai.com | Private 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 events | Workspace auth, sessions, and permissioned private memory |
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
- Buy
scratchnode.live— Cloudflare Registrar (at-cost, free WHOIS privacy, no upsells). Turn on auto-renew, 2FA, registrar lock. - Point nameservers to Vercel — or use Cloudflare's DNS with proxy OFF for the apex (Vercel handles cert + edge). On Cloudflare:
Arecord@→76.76.21.21,CNAMEwww→cname.vercel-dns.com. - Add the domain to the existing Vercel project — Project Settings → Domains → Add
scratchnode.live+www.scratchnode.live. Both auto-issue Let's Encrypt certs. - Deploy — push to
main. Vercel builds once and serves both hosts from the same artifacts. - 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
| Layer | Shape |
|---|---|
| Convex | Single deployment. Both UIs query the same project. Permissions checks gate cross-product reads. |
| Stripe | One account, two product lines (workspace_pro, event_host_pro). Webhooks go to nodebenchai.com/api/stripe. |
| Analytics | One Posthog/GA4 property. Stitch cross-domain sessions with linker: ['nodebenchai.com', 'scratchnode.live']. |
Resend / Postmark. events@scratchnode.live for guest comms, hello@nodebenchai.com for host/account comms. | |
| Convex env | Add 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
- Two Vercel projects until ScratchNode forks meaningfully (probably 6+ months). Doubles build minutes and preview-deploy chains for no benefit.
- Shared session cookies via subdomain trick.
.comand.livecan't share. Use redirect handshake instead — and skip it entirely in v0 (anonymous guests). - Hard-coding domain in source. Use
PUBLIC_BASE_URLderived fromlocation.hostnameso preview deploys work. Only the canonical OG tags referencescratchnode.liveliterally. - Claiming "deployed" on
git pushexit 0. Always fetch the live URL and grep for a concrete content signal. See Live verify.
Live prod plan: prototype → real #
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)
| Capability | Today | Production target |
|---|---|---|
| Shared chat between visitors | Convex realtime query + presence | Rate limits, moderation, and account upgrade path |
/ask agent answers | Convex action → Anthropic when configured, deterministic fallback otherwise | Add richer eval sets, rate limits, and provider budgets |
| Sources / citation counts | Convex source records + answer source chips | Expand provider/source ingest coverage |
| Private notes | Convex per-session table with owner-key gating + anchor metadata | ScratchNode account sync + encrypted per-user store |
| Identity / display name | localStorage | Anon session UUID + optional auth upgrade |
| FAQ promote / wiki publish | Convex mutations with host gate | Reviewer workflow, audit trail, and stronger policy UI |
| Sign-in | Guest/session + host-code auth | ScratchNode magic link for joined/hosted events and synced notes; NodeBench sign-in only for workspace continuation |
| Event wiki | Convex-backed, version snapshotted | Scheduled compaction and richer source grouping |
| Semantic answer cache | Exact normalized public-answer cache | Redis/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).
A real URL anyone can visit, branded, fast, on production-grade infra.
- Domain
scratchnode.liveregistered on Cloudflare - DNS: A @ → 76.76.21.21 (DNS-only) + CNAME www → cname.vercel-dns.com
- Vercel: apex Production + www → 307 → apex
-
vercel.jsonhost-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
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.ts—getEventBySlug(slug),getMessages(eventId, limit, beforeCursor),getMembers(eventId) -
convex/events/mutations.ts—joinEvent({slug, sessionId, displayName}),sendMessage({eventId, sessionId, text, kind, replyToMessageId?}),heartbeat({eventId, sessionId}) -
convex/events/crons.ts— janitor that evictseventMemberswithlastSeenAt < now - 5min - Seed mutation for the demo event
ai-infra-summit-2026with codeORBITAL
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 importsConvexClientfrom 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')orcrypto.randomUUID() - On page load: call
joinEvent({slug, sessionId, displayName})where slug = URL path's/e/<slug> - Subscribe to
getMessagesvia Convex realtime — append/dedupe rows in#feedas they stream in - Replace
postPublicChat()+postPublicAsk()DOM-append withsendMessagemutation (kind=chat or ask) - Add 30-second
setIntervalforheartbeatto keep presence alive - Wire
onbeforeunloadto 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).
/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.ts—askAgent({eventId, question, sessionId}): bounded public-source retrieval, prioreventAnswerscache lookup, optional Linkup freshness probe, Anthropic provider call when configured, deterministic fallback, quality gate, and persisted{answerId, body, sourceIds, trace} - Replace
streamAgentAnswer'ssetTimeoutwith:client.action('events:askAgent', {eventId, question, sessionId}), falling back toevents:composeAnsweronly if actions are unavailable - Render
result.sourcesas the source chips (with real URLs fromeventSources) - 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 intoeventSources - 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.
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.ts—createNote,updateNote,pinNote,deleteNote(all gated byownerKey === ctx.session.ownerKey) -
convex/notes/queries.ts—listMyNotes(eventId?)returns only notes whereownerKey === currentOwnerKey - Replace
window._notes_v5read paths with Convex query subscription - Replace
createNewNote/updateActiveNote/togglePinNote/deleteActiveNotewith 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.
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.livereads the session token from hash, exchanges it for a Convex session viaauth/exchangeScratchNodeSessionaction, sets a same-site cookie scoped to.scratchnode.live -
convex/events/mutations.ts— add server-side guardrequireHostRole(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.
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.ts—compactEventWiki({eventId}): pull all promoted FAQ entries, source list, member count; generate sections via a templated Anthropic call; insert as neweventWikiVersionwithstatus=draft -
publishWikiVersion({wikiVersionId})mutation — flips draft→published; setsevents.status=ended - UI: the existing wiki sheet renders from the latest
publishedversion (replacing hardcoded HTML) - SEO: server-render the wiki at
scratchnode.live/e/<slug>/wikion Vercel edge so crawlers get the real content (not the SPA shell) - Add Open Graph image generation via existing
/api/og/[id].tsxroute — 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.
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.ts—lookup(question, eventId, visibility, wikiVersion)returns cached answer if similarity ≥ 0.92 ANDsourceBundleVersionmatches - Modify
askAgentaction: callsemanticCache.lookupfirst; on hit, return cached entry + trace markscacheHit=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.
Production-grade UX, observability, and growth loops on top of the working product.
- TipTap/ProseMirror replaces
execCommandfor the notes editor (rich text + mentions + backlinks model) - Client-side QR code generation (replace external
api.qrserver.comwith 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
/askvia 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_protier for unlimited + custom branding - Replay link: events that ended can be replayed at original timestamps via a scrubber
Cumulative timeline
| After phase | Cumulative time | What's live |
|---|---|---|
| 0 | 0 d | Static prototype at scratchnode.live (today) |
| 1 | ~3 d | Real multi-user chat. Strangers see each other's messages. |
| 2 | ~7 d | Real /ask with sourced answers grounded in event corpus. |
| 3 | ~9 d | Notes persist across reload. Minimum viable "real" product. |
| 4 | ~12 d | Hosts can create events, moderate, publish; server-enforced auth. |
| 5 | ~16 d | Event wiki is real. SEO-indexable. Shareable forever. |
| 6 | ~28 d | Production-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):
- Current live dogfood → share with 5 to 10 trusted people for "does this feel real?" and /ask quality calibration
- Next hardening pass → run a friend's actual upcoming event as a small beta room with host review enabled
- Self-serve readiness → open event creation only after auth, abuse controls, billing/rate limits, and provider-backed deep research are verified
- Public launch → launch when event setup, crawlable wiki, source cache, and QA videos show stable before/during/after flows
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.
- Room code — short pronounceable identifier (e.g.
ORBITAL) joins anyone. - Public chat — what everyone sees. Type to talk to the room.
- /ask — agent that drafts a sourced answer from the event's sources, transcripts, and prior Q&A. Renders as a nested child block under the question.
- @mention — picks a person from the roster and inserts a reference chip. v5 prototype renders the chip only — no notification endpoint is called. See the privacy invariants for details.
- Reply — quotes a row and creates a thread badge on the response.
- React — emoji reactions per row.
- Private notes — switch the composer lock on, anything you send goes to only your notebook.
- Wiki — compacts public chat + /ask answers + host-uploaded sources into an SDK-style published page.
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
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.
| Surface | Purpose | How to open |
|---|---|---|
| Hero + composer | Type to chat the room | Default landing |
| Wiki sheet | SDK-style live wiki | openSheet('wiki') |
| Notes sheet | Apple+Mem editor | openSheet('notes') |
| People sheet | Roster + presence | openSheet('people') |
| Share sheet | QR + platforms | openSheet('share') |
| Host sheet | Create your own event | openSheet('host') |
| Sign-in sheet | Magic-link auth | openSheet('signin') |
Composer & modes #
The composer is the only persistent input. It supports four input affordances:
- Type — plain message to the room.
- /ask <question> — agent draft, sourced.
- @<name> — opens the mention picker.
- Reply — context badge above the composer pinning the quoted row.
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:
- Reply — populates the composer with a reply context badge.
- React — pops an emoji picker; selected emoji becomes a row-bottom chip.
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:
- Never enters the public feed (release-blocker invariant).
- Routes to the rich notes store (
window._notes_v5). - Triggers a centered toast: "🔒 Private note saved — View all notes →".
- Increments the
🔒 N notesbadge in the identity row.
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:
- Left rail: sticky TOC with active-section highlight (IntersectionObserver scroll-spy).
- Center: article with H1/H2 anchors, syntax-highlighted code blocks, callouts, tables, blockquotes, source chips.
- Right rail: on-page navigation (hidden <1024px).
Below 720px, the layout collapses to a single-column readable article (TOC and on-page rails hidden).
Selectors
| Class | Role |
|---|---|
.wiki-shell | 3-column CSS grid |
.wiki-toc | Sticky left rail with .wiki-toc-links |
.wiki-article | Main typography column |
.wiki-callout | Notion-style purple callout |
.wiki-src-chips | Source-citation chip row |
.wiki-onpage | Right-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
- List pane: pinned section, "Today / Yesterday / Earlier this week / Older" date headers, tag chips, /ask badge, fuzzy search across title+body+tags.
- Editor pane: large title input, auto-extracted tag chips, TipTap-style toolbar (B / I / U / S / H1 / H2 / bullet / numbered / checkbox / quote / @ / #).
- Auto-save: every keystroke updates
_notes_v5; saved indicator transitions "Saving…" → "✓ Saved" after 400ms idle. - Backlinks (Mem.ai-style): shown below the editor when other notes @mention this note's title or share a
#tag. - Pin / Copy / Delete: header actions on every active note.
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)
| Key | Action |
|---|---|
| ⌘+N | New note |
| ⌘+P | Pin / unpin active note |
| ⌘+Shift+K | Focus search |
| ⌘+B / I / U | Bold / 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
sn_v5_first_visit_done— set after first sessionsn_v5_last_view_mode— restored on returnsn_v5_tour_done— onboarding completion
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>.
| Mode | Audience | Trigger |
|---|---|---|
landing | Marketing / first-touch | cold start, no localStorage |
workspace | Returning power user | setViewMode('workspace') |
public_event | Attendees in a live room | room URL or setViewMode('public_event') |
host_console | Event host live ops | setViewMode('host_console') |
Notebook · Library · Chat · Stage #
Inside workspace, v4 exposes four tabs:
- Notebook — your private thinking. v5 collapsed this into the notes sheet.
- Library — saved sources, transcripts, attachments. v5 hides; surfaces via /ask citations only.
- Chat — public room. Identical concept to v5's main feed.
- Stage — the host's curated picks. v5 folds into the wiki.
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 #
| Surface | Mobile (<720px) | Web (≥720px) |
|---|---|---|
| Sheet | Slides up from bottom, full-width | Centered modal, capped width |
| Wiki TOC + on-page | Hidden — readable article only | 3-column SDK layout |
| Notes pane | Single-pane; tap row → editor; ← back | Two-pane (list 260px + editor) |
| Hover actions | Long-press → bottom-sheet menu | Hover reveal, bottom-right |
| Mention picker | Docks above keyboard | Floats at caret |
| Onboarding tour | Pulse-positioned, vertical | Pulse-positioned, with arrows |
| Keyboard shortcut overlay | Hidden (no keyboard) | ? opens modal |
| Haptics | navigator.vibrate on every action | No-op |
| Voice button | Native push-to-talk feel | Click-to-record |
| Share | navigator.share opens native sheet | Platform 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:
- 4ms — selection / row tap
- 6ms — sheet open
- 8ms — send
- 10ms — destructive (delete)
- 12-15ms — confirmation / sign-in
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
| Hash | Effect |
|---|---|
/demo_ver1 | Triggers 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 / 3 | Jump directly to onboarding step N. |
#empty | Clears the feed and shows the empty-state CTA. |
#viewmode=workspace | (v4) sets view mode without going through landing. |
?debug=1 | Exposes 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.
| Key | Where | Action |
|---|---|---|
| ? | Anywhere (no input focused) | Open shortcut overlay |
| Escape | Sheet / overlay open | Close |
| ⌘+S | Anywhere | Open share sheet |
| ⌘+K | Anywhere | Focus composer |
| ↑ / ↓ | Mention picker open | Navigate |
| Enter | Mention picker open | Insert selected |
| ⌘+N | Notes sheet | New note |
| ⌘+P | Notes sheet | Pin / 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
- Terracotta = primary CTAs, branding, active state, /ask agent.
- Purple = private mode, personal notes, user-only data.
- Green = online presence, saved indicator, verified.
localStorage keys #
| Key | Value | Purpose |
|---|---|---|
sn_v5_seen | '1' | Returning-user flag |
sn_v5_name | string | Display name |
sn_v5_first_visit_done | '1' | Skip first-visit landing |
sn_v5_last_view_mode | view name | Restored 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 |
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.
- Private mode never writes to the public feed.
sendshort-circuits before#feedinsert whendata-mode === 'private'. - Private notes never feed the public wiki. The wiki compaction reads only public messages, /ask answers, and host-uploaded sources.
- UI promise matches data path. The composer reads "only your notebook" while purple — the code path matches that string.
- Mentions ≠ notifications. Inserting
@Alexrenders a pill but does not call a notification endpoint in v5. - v4 stays local; v5 is live.
home-v4.htmlstores only browser-local prototype data.home-v5.htmlwrites public room data and private owner-key-scoped notes to Convex when loaded throughscratchnode.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.