A social layer for the personal web. People connect their websites, follow each other, and interact via Notch — a JS widget injected into any site.
Everyone signs in the same way — passwordless email OTP, Google, or GitHub — and gets one site. There are two kinds of site:
External site — the user connects a website they already own by pasting the Notch <script> tag. site.kind = 'external', domain is their own domain. Social-only; no agent.
Generated site — the user picks a free name.vetka.sh address and the Anthropic Managed Agent builds a static React/Tailwind site that Vetka hosts (served from object storage). site.kind = 'generated', subdomain is the label, domain is <subdomain>.vetka.sh.
Both converge on the same social layer: follow, feed, reactions, messages.
getPostLoginDestination() sends the user to / if they already have a site, otherwise to /setup.
Two options: "Connect an existing website" → /setup/script, or "Generate a new site" → /setup/generate.
beforeLoadguards auth- User picks a subdomain label →
checkSubdomain()validates availability →createSite({ kind: 'generated', subdomain }) - Redirect to
/sites/$domain/builderwhere the agent builds it
beforeLoadguards auth- User enters domain → sees
<script>tag to paste - Polls
/api/notch/check?domain=every 5s → on detection,createSite({ kind: 'external' })→ redirect to/
Split view: iframe (live site preview) + chat (Anthropic Managed Agent). A "Versions" tab lists deploy snapshots and can roll back. Auth guard in beforeLoad; agent session fetched client-side via /api/agent/session.
/ Hub: feed, notifications, new members, login modal
/setup Onboarding choice (connect existing vs generate)
/setup/script Paste script tag (external site)
/setup/generate Pick a *.web.sh subdomain (generated site)
/sites User's sites list (one site max for now)
/sites/$domain/builder Agent + live preview + versions
/api/auth/* BetterAuth (email OTP + Google + GitHub, session)
/api/agent/session Get or create Anthropic Managed Agent session
/api/agent/stream SSE stream for agent responses
/api/agent/deploy Deploy relay — agent POSTs built files (Bearer = short-lived deploy token)
/api/serve/$ Static serving for *.web.sh (resolves Host → site → storage)
/api/sites/$domain/snapshots List deploy snapshots / POST to roll back
/api/notch/me CORS + credentials — returns current user for Notch widget
/api/notch/check Server-side check if notch.js is on a domain
user BetterAuth core
session BetterAuth core
account BetterAuth core (OAuth tokens; no passwords — OTP/Google only)
verification BetterAuth core (also holds email OTP codes)
agentSession userId(unique) → user · sessionId (Anthropic session ID)
deployToken siteId → site · tokenHash(unique) · expiresAt (short-lived deploy creds)
site id · domain(unique) · userId → user · kind(external|generated)
· subdomain(unique, generated only) · status(draft|building|live|error)
· buildLog · liveSnapshotId → siteSnapshot (current live version)
siteSnapshot id · siteId → site · storagePrefix · fileCount · byteSize · message
· status(pending|building|success|failed) · triggeredBy(agent|manual)
siteImage siteId → site · WebP blob (page thumbnail; 16:9 1280×720, scrollbar-free)
follow followerId → site · followeeId → site (unique pair)
message fromId → site · toId → site · body · readAt
reaction pageUrl(indexed) · siteId → site · authorUserId → user
· emoji · x · y (0–100% position) · body (optional comment)
Schema is applied with bunx drizzle-kit push (interactive). For the Tangled→generated-sites
migration, scripts/migrate-remove-tangled.sql has the equivalent raw SQL.
Use loaders and beforeLoad — not useEffect — for auth guards and server-fetched data.
export const Route = createFileRoute('/some/route')({
// Auth guard — runs on server, throws redirect before render
beforeLoad: async () => {
const session = await getAuthSession()
if (!session?.user) throw redirect({ to: '/' })
},
// Data fetching — runs on server, available immediately
loader: async () => {
const sites = await getUserSites()
return { sites }
},
component: MyPage,
})
function MyPage() {
const { sites } = Route.useLoaderData() // no loading state needed
// ...
}Exceptions — keep client-side:
listRepos()— reads AT Protocol OAuth session from browser localStorage- Agent session fetch — creates session on demand, not safe to deduplicate in loader
API routes use server.handlers inside createFileRoute:
export const Route = createFileRoute('/api/something')({
server: {
handlers: {
GET: async ({ request }) => Response.json({ ok: true }),
POST: async ({ request }) => { ... },
},
},
})Do NOT use createAPIFileRoute from @tanstack/react-start/api — it doesn't exist in this version.
Server functions (createServerFn) are the right abstraction for shared server logic called from both loaders and client event handlers:
export const getUserSites = createServerFn({ method: 'GET' }).handler(async () => {
const session = await getAuthSession()
if (!session?.user) return []
return db.select().from(schema.site).where(eq(schema.site.userId, session.user.id))
})Passwordless only.
| Provider | Flow |
|---|---|
| Email OTP | BetterAuth emailOTP plugin. authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' }) emails a 6-digit code (via sendOtpEmail in email.server.ts — Resend if RESEND_API_KEY is set, else console-logged in dev), then signIn.emailOtp({ email, otp }). First-time emails auto-create the user. |
BetterAuth social provider, enabled when GOOGLE_CLIENT_ID is set. signIn.social({ provider: 'google' }). |
|
| GitHub | BetterAuth social provider, enabled when GITHUB_CLIENT_ID is set. signIn.social({ provider: 'github' }). |
trustedOrigins in auth.server.ts must include all domains (vetka.sh, tailscale URL, localhost).
disableCSRFCheck: true is set for non-production to allow cross-origin dev logins.
Generated sites are static files in object storage, served from the wildcard subdomain.
- Storage (
src/lib/storage.server.ts): aStorageinterface with two drivers —local(filesystem underSTORAGE_LOCAL_DIR, dev default) ands3(any S3-compatible bucket; defaults target Cloudflare R2 viaR2_ENDPOINT/R2_BUCKET, also works with AWS S3). Selected bySTORAGE_DRIVERor the presence of bucket creds. Layout:sites/<id>/live/(served) andsites/<id>/snapshots/<snapshotId>/(immutable versions). - Deploy (
src/lib/deploy.server.ts+/api/agent/deploy): the agent bundles with bun, calls theget_deploy_credentialscustom tool to get a short-lived per-site deploy token (src/lib/deploy-token.server.ts, default 2h, stored hashed indeploy_token), then POSTs the built files (JSON{ files: [{ path, contentBase64 }] },Bearer <deploy token>). The token determines the target site (the agent can't deploy elsewhere). Each deploy writes a snapshot, republishes it tolive/, and pointssite.liveSnapshotIdat it.rollbackSite()re-publishes an older snapshot. On an expired token the relay returns 401code: "token_expired"and the agent refreshes via the tool. - Serving (
/api/serve/$): resolves the requestHost(<sub>.vetka.sh) →site.subdomain→ storagelive/prefix and streams the file (SPA fallback toindex.html).*.vetka.shis configured as a wildcard domain on the Vercel project (registered through Vercel DNS — no extra DNS records needed). All*.vetka.shrequests are rewritten to/api/serve/$pathviavercel.json, preserving the originalHostheader so the serve handler can extract the subdomain. TheX-Vetka-Subdomainheader is also accepted as an override (useful for testing:curl vetka.sh/api/serve/ -H "X-Vetka-Subdomain: name").
The widget runs on third-party sites and calls vetka.sh/api/notch/* with credentials: 'include'. Requires:
- CORS: reflect
Originheader (not*),Access-Control-Allow-Credentials: true - Cookie:
sameSite: 'none', secure: truein production (auth.server.ts)
- TanStack Start (Vite + React + SSR) + Tailwind v4 + TypeScript
- BetterAuth 1.6 — sessions, DB-backed (email OTP + Google + GitHub)
- Anthropic Managed Agents SDK — persistent per-user agent sessions (build generated sites)
@aws-sdk/client-s3— S3-compatible object storage (Cloudflare R2 / AWS S3) for hosted sites- Drizzle ORM + postgres.js → Aiven PostgreSQL 17
- bunup — Notch widget bundler (
notch/→public/notch.js)
- App:
bun run dev - Notch widget:
bun run dev:notch— must run in a separate terminal; watchesnotch/src/and rebuildspublic/notch.json every change. Without this, the widget served at/notch.jsis a stale build. - Schema changes: edit
src/db/schema.ts→bunx drizzle-kit push(needs interactive TTY — run in terminal with!) - Nitro is excluded from dev (
vite.config.ts) to avoid breaking TanStack's dev middleware - Same Aiven PostgreSQL instance used for dev and prod (hackathon)
design/reactions/— reaction-sticker pack + treatment spec (the 8 Vetka Signals,REACTIONS.md, browser preview). The reactions overlay UI is specced but unbuilt: backend is ready (reactiontable +/api/notch/reactions), but thereactionsbutton innotch/src/Widget.tsxis an inert stub (no onClick, no overlay/picker/stamp rendering). Seedesign/reactions/README.md.- Other design handoffs live in the gitignored
local-drafts/— not on GitHub.
Docs: https://platform.claude.com/docs/en/managed-agents/overview
We use a single global agent (not per-user) with per-user sessions. The agent holds the system prompt and tool config; the session is the live sandbox + conversation history for one user.
Constants in src/lib/agent.server.ts (overridable via ANTHROPIC_AGENT_ID / ANTHROPIC_ENV_ID):
AGENT_ID = 'agent_019VzGQn8ggkHmQxrDrHcJjU'— managed in the Anthropic consoleENV_ID = 'env_01AKeJed2CAzKMdAMmQ3zTnN'— the cloud sandbox environment (Linux container)
To update the system prompt/tools, edit and re-run scripts/update-agent.mjs — it retrieves the current agent version (required for optimistic locking) then calls client.beta.agents.update().
GET /api/agent/session— callsgetOrCreateSession(userId), which looks upagentSessiontable or callsclient.beta.sessions.create({ agent: AGENT_ID, environment_id: ENV_ID })and persists the newsessionId(no SSH keys — deploys are storage-based). Then loads history viaclient.beta.sessions.events.list(sessionId), sorts byprocessed_at, and reconstructsChatMessage[].POST /api/agent/stream— prepends a<vetka_context>block (site id, prod URL, deploy curl template) to the user message, sends it viaclient.beta.sessions.events.send(), then streams back SSE events untilsession.status_idle. The agent calls theget_deploy_credentialscustom tool, which the stream handler services by minting a short-lived deploy token (real token to the agent, redacted in the client stream); the agent then curls/api/agent/deploywith it.
Tool events are separate stream events, not embedded in agent.message.content (which only carries TextBlock[]):
| SDK event | What it is |
|---|---|
agent.thinking |
Agent is reasoning |
agent.message |
Text response blocks |
agent.tool_use |
Built-in tool call |
agent.mcp_tool_use |
MCP tool call (has mcp_server_name) |
agent.custom_tool_use |
Custom tool call |
agent.tool_result |
Result for agent.tool_use |
agent.mcp_tool_result |
Result for agent.mcp_tool_use (key: mcp_tool_use_id) |
session.status_idle |
Agent finished — stop streaming |
Each user message gets a <vetka_context> XML prefix with the generated site's prod URL and deploy-relay instructions. When reconstructing history in session.ts, this prefix is stripped via regex before returning messages to the client.
MCP servers must be public HTTPS URLs — the Anthropic platform calls them, not the agent sandbox. Configure via mcp_servers: [{ type: 'url', name, url }] in agent or session config.
Environments support packages.pip / packages.npm / packages.apt to pre-install dependencies before the agent starts (cached across sessions). To add packages, create or update an environment via client.beta.environments.create/update. The current ENV_ID is the default cloud environment.
Use Drizzle directly from a scripts/*.ts file and run with bun --env-file=.env scripts/your-script.ts:
import { db } from '../src/db'
import { agentSession } from '../src/db/schema'
import { eq } from 'drizzle-orm'
const deleted = await db.delete(agentSession).where(eq(agentSession.userId, 'abc')).returning()
console.log(deleted)
process.exit(0)See scripts/clear-session.ts for a working example.