Soft caps on the two resources a bad-actor signup could pump fastest:
- 3 active projects per workspace
- 3 active (running/provisioning) dev containers per workspace
Suspended dev containers don't count (they're free), so a power
user can have many projects with most containers idle. Limits are
overridable via env vars (VIBN_QUOTA_MAX_*) for a global lift.
Hits surface as HTTP 402 with structured payload {error, code,
current, limit}. AI's error-recovery middleware matches the
QUOTA_EXCEEDED code and synthesizes guidance: tell the user which
cap was hit, offer to suspend something or contact support, do NOT
retry blindly.
Wired:
- lib/quotas.ts — assertProjectQuota,
assertDevContainerQuota,
getQuotaStatus
- app/api/projects/create/route.ts — checks before create
- lib/dev-container.ts — checks before resume +
net-new ensure
- app/api/mcp/route.ts — devcontainer.ensure
translates QuotaExceededError
to 402
- lib/ai/error-recovery.ts — workspace-quota-exceeded rule
Closes BETA_LAUNCH_PLAN.md task 4.6.
Co-authored-by: Cursor <cursoragent@cursor.com>
Implements all 4 stages from SENTRY_AS_PRODUCT.md:
Stage 1 — Auto-provision per-project Sentry:
- New module lib/integrations/sentry.ts with idempotent
ensureSentryProject(): creates Sentry project under shared
vibnai org, fetches DSN, persists to fs_projects.data.sentry.
- Wired into POST /api/projects/create (provision early so DSN is
ready before first deploy) and into applyEnvsAndDeploy in MCP
(lazy retry + env var injection on every apps.create).
- applySentryEnvToCoolifyApp upserts NEXT_PUBLIC_SENTRY_DSN +
SENTRY_AUTH_TOKEN onto the Coolify app, so the very first build
inlines the DSN into the client bundle and uploads source maps.
Stage 2 — Bake into scaffolds:
- New module lib/scaffold/sentry-snippets.ts exposes canonical
Next.js + Vite+React snippets the AI copies verbatim (keeps
outputs deterministic across chats).
- AI system prompt updated: explicit instructions to wire Sentry
on every new app, env vars are guaranteed available, project
Sentry slug comes from projects_get.
- projects.get MCP response now includes `sentry: {slug, dsn,
provisionedAt}` so the AI can substitute the slug into
withSentryConfig({ project: <slug> }).
Stage 3 — Expose error feed to the AI:
- Three new MCP tools registered:
project_recent_errors — list unresolved issues
project_error_detail — stack trace + breadcrumbs + replay url
project_error_resolve — mark resolved after a verified fix
- Tenant-safe: each tool re-checks projectId belongs to caller's
workspace before talking to Sentry.
Stage 4 — Auto-surface at chat-turn start:
- chat/route.ts pulls listRecentSentryIssues for the active
project (last 6h, count ≥ 2 to skip noise) and appends a
[PROJECT HEALTH] block to the system prompt. AI decides
whether to surface a one-liner; if user's message is about a
broken thing, AI prefers Sentry stack trace over guessing.
End state: a Vibn user's deployed app crashes for a real user →
Sentry captures with source-mapped stack trace + Session Replay →
next AI chat turn the AI knows about it and can offer a fix
without the user pasting the error.
Co-authored-by: Cursor <cursoragent@cursor.com>
3.4: Bumped MAX_VISIBLE chips to 3 (was 2). When >3 endpoints exist,
extras collapse into a "+N" pill that opens a click-to-open popover
listing the rest as real clickable links. Replaces the previous
title-attribute tooltip which was undiscoverable on touch and
keyboard. Closes on outside click and Escape.
3.5: Status pill "Logs" affordance now deep-links to the Coolify
project page (one click from build logs) instead of Coolify's root.
Also surfaced on `deploying` and `down` states, not just
`build_failed` — when something's happening Coolify-side, users
want to peek regardless of the exact state.
Co-authored-by: Cursor <cursoragent@cursor.com>
NEXT_PUBLIC_SENTRY_DSN must be present at build time so Next.js
inlines it into the client bundle. SENTRY_AUTH_TOKEN must be
present at build time so withSentryConfig can upload source maps
to Sentry. Coolify already passes both as --build-arg; this
declares the matching ARG lines so the builder stage actually
consumes them.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds @sentry/nextjs v10 with the Next.js 16 instrumentation pattern:
- instrumentation.ts — server + edge runtime init
- instrumentation-client.ts — browser init with Session Replay
(free tier, mask all text/inputs by
default since chat content is sensitive)
- app/global-error.tsx — catches root-layout crashes that escape
every other error boundary
- app/sentry-example-page — verification page; click both buttons
- next.config.ts — wrapped with withSentryConfig, source
maps upload to Sentry on every build,
client error events tunneled through
/monitoring to bypass ad-blockers
Runtime capture works as soon as NEXT_PUBLIC_SENTRY_DSN is in Coolify
env (already added). Full source-map de-minification of prod stack
traces requires SENTRY_AUTH_TOKEN in Coolify env — pending user.
Co-authored-by: Cursor <cursoragent@cursor.com>
Pattern-matches known-recoverable MCP tool failures and injects a
synthetic imperative message into the conversation right after the
failing tool result. Static prompt rules lose to accumulated tool
reality (we've shipped 4 orphan twenty-* services because the model
ignored the "no delete-and-recreate" rule); a fresh role:'user'
message at decision time does not.
Initial rules cover the three highest-confidence Docker failure
patterns: orphan container conflict (use apps_unstick), image pull
denied (use apps_repair), port already allocated (identify holder).
Each rule names the wrong-but-tempting move explicitly.
See AI_HARNESS_GAPS.md §1 for the failure case this addresses.
Spike on 2026-05-01 confirmed HMR works end-to-end through Traefik
when ALL of these are set:
server: {
host: '0.0.0.0',
port: <3000-3009>,
strictPort: true,
hmr: { clientPort: 443, protocol: 'wss', host: '<previewUrl host>' },
}
The previous prompt omitted hmr.host, which lets Vite's HMR client
guess the wrong host and silently fail the WS upgrade. Adding the
host explicitly. Verified test: 101 Switching Protocols, vite-hmr
subprotocol negotiated, js-update messages fire within ~1s.
Co-authored-by: Cursor <cursoragent@cursor.com>
The bug: 12 rounds of model thinking were concatenated into one giant
msg.thoughts string and rendered as a single ThinkingBubble blob. User
saw a wall of meta-narrated reasoning ("Okay, here's my interpretation
of those thoughts..." x12) with no visual breaks between rounds.
Replace separate thoughts/toolEvents fields on Message with a single
chronological timeline of mixed entries:
type TimelineEntry =
| { kind: "thought"; text: string }
| { kind: "tool"; name: string; status; result? }
Each thought event from the SSE stream becomes its own timeline entry
and renders as its own collapsed ThinkingBubble pill. Adjacent same-
named tool entries still collapse into a ×N TimelineToolGroup.
Visual flow now matches what actually happened in the turn:
[thought pill] [dev_server.list ✓] [thought pill]
[dev_server.stop ×2 ✓] [thought pill] [shell.exec ×2 ✓]
[final summary text]
Each pill is independently expandable, so a user who wants to read
the model's reasoning for round 7 can click round 7 — they don't
have to scroll through a single 4000-char blob.
Made-with: Cursor
Two UX wins from this change:
1. Tool pills now render INSIDE the assistant message bubble, ABOVE
the streaming text. Previously: messages list rendered first, tool
tray rendered after — so the user saw the summary text mid-screen
and the tool pills below it, having to scroll UP to read the
summary they actually cared about. Now the visual flow is:
[user question] → [tool pills, chronological] → [summary text]
and the user's eye lands on the summary at the bottom.
2. Adjacent runs of the same tool name collapse into a single pill
with ×N counter. Click to expand. Kills the "8× shell.exec ✓"
wall of identical pills the user saw in prod (Dr Dave thread).
Tool events are now scoped to their owning assistant message
(message.toolEvents) rather than a global state — also means
stop/restart can't bleed events across turns.
Made-with: Cursor
The big UX failure: model fires 20 tool calls in silence, persists turn
with content_len=0, user has to re-prompt to get any answer. Confirmed
in prod (Dr Dave / "are you able to give me a preview url?" thread).
Five changes:
1. Recovery summary now fires on ANY silent-tool-tray turn end (not just
MAX_TOOL_ROUNDS): hit the cap, broke a detected loop, OR ended with
empty assistantText. Previously the recovery was gated to round-cap
only, so voluntary silent stops slipped through.
2. Recovery summary has a deterministic fallback. If Gemini returns
empty text on the recovery call, emit a static "ran N tools, didn't
reach a clean stopping point" message instead of silently swallowing
the empty string. The user always gets something readable.
3. Loop detection: track tool-call fingerprints (name + first 120
chars of args) per turn; if the same fingerprint fires 3× within
the last 8 calls, break the loop and surface to user via recovery
summary. Kills the dev_server.start → logs → stop → start → ...
pattern at its root.
4. Status nudge every 4 silent rounds: inject a synthetic system
instruction telling the model to send a one-liner before any more
tool calls. The user's only signal of life on long chains.
5. Prompt: soften "don't narrate intent" → "don't narrate SINGLE
calls; on chains 3+ deep send a one-liner before each batch".
Adds explicit "never end a turn silent" rule.
Also: error-path now uses safeClose() instead of bare controller.close()
to honor the streamClosed guard like every other close site.
Made-with: Cursor
The *.preview.vibnai.com wildcard cert is issued via DNS-01 and stored
under the letsencrypt-dns resolver. Using letsencrypt (HTTP challenge)
would cause Traefik to attempt individual per-subdomain certs instead
of using the existing wildcard.
Made-with: Cursor
\` is not a valid escape sequence in YAML double-quoted strings,
causing Coolify to fail parsing the dev-container compose on provision.
Backticks are literal characters in YAML double-quoted strings and
need no escaping — only the JS template literal escape (\`) is required.
Made-with: Cursor
Behind Coolify's proxy, req.url resolves to 0.0.0.0:3000 which GitHub
rejects as an unregistered redirect URI. Prefer NEXTAUTH_URL env var.
Made-with: Cursor
Move project settings page from bare /settings into (home) route group so it
shares the project-header layout; delete the conflicting (workspace)/settings
page that was causing "two parallel pages resolve to the same path" error.
Made-with: Cursor
- Plan/Product/Infrastructure: empty states now suggest a concrete AI
prompt so non-technical users know exactly what to type rather than
staring at a blank category ("Try: Add a Postgres database…")
- Workspace settings danger zone: wired Delete Workspace to a new
POST /api/workspaces/delete endpoint (deletes projects + chat
threads; Coolify resources intentionally untouched)
Made-with: Cursor
- next.config.ts: add react-markdown + entire unified/remark/rehype
ecosystem to transpilePackages — fixes TypeError 'z'/'j'/'aa' prod
crashes caused by ESM-only packages not being bundled for webpack
- Dockerfile: bake HEALTHCHECK --start-period=60s on 127.0.0.1 so
rolling deploys pass on first health probe (was failing on ::1 IPv6)
- Hosting tab: full rewrite — live URL chip, copy button, redeploy
button, inline log viewer, domain list, empty state with prompt
nudge. Single-card layout replaces master-detail for 1-3 endpoints.
- Settings page: new /project/:id/settings route with danger zone +
typed "delete" confirmation for project deletion
- Status pill: "View logs" link appears on build failures
- URL chips: collapse extras into "+N more" pill when >2 visible
- Chat errors: structured "Tool error:" prefix; network errors
distinguished from server errors
Made-with: Cursor
Coolify stores user-facing domains on the inner application of a
service (e.g. Twenty's `twenty` app), not on the parent service. The
anatomy endpoint was reading the service-level fqdn and getting null,
so live URL chips never rendered.
- smartServiceMetaFor (replaces smartServiceStatusFor): collects
fqdns from non-excluded inner apps in addition to status
- prioritiseFqdns: pushes auto-generated *.sslip.io / *.coolify.app
URLs to the back so real custom domains surface first
- fqdnsOf: strips default ports (443, 80, container 3000) so chips
link to the public Traefik-served URL, not the internal port
Made-with: Cursor
- Status pill derives richer states (Empty / Deploying / Build failed /
Down / Live) from anatomy with self-polling while deploying
- Tooltip explains what's happening (last build status, transient
containers, Coolify build phase)
- New ProjectHeaderUrls component renders clickable chips for live
domains and active dev-server preview URLs to the left of the pill
- useAnatomy gains pollMs option for client-driven refresh
Made-with: Cursor
Coolify's Service::parse() (called inside the deploy job) iterates ALL
ServiceApplications in a multi-container template and overwrites
SERVICE_URL_<NAME> / SERVICE_FQDN_<NAME> env vars based on whichever
inner app's fqdn it sees first — frequently a worker still pointing at
the auto-generated *.sslip.io fallback. The result: the user's custom
domain is saved, the SPA loads, but its baked-in REACT_APP_SERVER_BASE_URL
points at sslip.io and every API call 404s.
apps_domains_set now substitutes ${SERVICE_URL_<NAME>} and
${SERVICE_FQDN_<NAME>} (with optional _<port> suffix) directly in the
docker_compose_raw before calling updateCompose(), and stops calling
service->parse() — so the literal URL survives any future redeploy.
Solves Twenty CRM domain bug; also unlocks reliable custom domains for
n8n, Plausible, Mautic, and every other Coolify service template that
follows the same SERVICE_URL_X pattern.
Made-with: Cursor
Two product gaps surfaced from the twenty-live debugging session:
1. Vibn frontend now has a healthcheck on / port 3000. Coolify will
wait for the new container to be healthy before swapping traffic,
so deploys no longer drop in-flight chat SSE streams. (Setting was
applied via Coolify API; commit just documents.)
2. apps_domains_set now handles SERVICES (template-based apps like
Twenty CRM, n8n) — not just applications. Setting service_apps.fqdn
in the DB alone gets reverted by Coolify's deploy pipeline, so we
replicate the Livewire EditDomain.php save flow via tinker over SSH:
write fqdn → save → updateCompose() → service.parse(). After this
apps_deploy regenerates Traefik labels with the custom domain.
Auto-detects service vs application by uuid lookup. New { port }
parameter lets the AI pin the upstream port for services that
require one (Coolify hard-fails the save without it).
Tool description rewritten with the new behavior + a worked example
so the AI uses the right pipeline first try.
Made-with: Cursor
Round two of AI-hardening based on what bit us with the twenty-* fan-out:
1. apps_create idempotency now covers ALL four pathways (template /
image / composeRaw / repo), not just templates. Same dedup-by-name
check inside the project, same alreadyExisted: true response shape.
Pass force: true to opt out for legitimate dev/staging duplicates.
2. databases_create gets the same idempotency treatment — and now
also scopes to the per-project Coolify project when projectId is
supplied (previously only apps_create did this).
3. New shared helper findExistingResourceByName scans apps + services
+ databases in a project and matches case-insensitively on name.
4. System prompt: three new hard rules teaching the model how to
handle tool results and anchor on reality:
- Tool results are authoritative; conversation history is not.
If a tool contradicts an earlier assertion, discard the
assertion. Don't keep telling the user it's broken when
apps_get now says it's healthy.
- When the user reports an error, FIRST tool call is a
current-state read (apps_get / databases_get / apps_logs).
Stop re-debugging problems that were already fixed.
- Trust idempotency. alreadyExisted means done; don't loop
trying a different name.
Made-with: Cursor
Three changes that compound to fix the "4 orphan twenty-* services"
problem we just hit:
1. apps_create is now idempotent within a project. If a service from
the same template already exists in the same Vibn projectId, return
it with alreadyExisted: true instead of creating a clone. Pass
{ force: true } to opt out for legitimate dev/staging duplicates.
2. New apps_unstick tool. SSH-cleans orphan Docker containers
matching the resource UUID so a deploy that hit "Conflict.
The container name X is already in use" can recover without
deleting the entire service.
3. System prompt hardened with two new hard rules:
- ALWAYS apps_list before apps_create (idempotency in spirit, not
just at the API boundary)
- NEVER delete-and-recreate a service to escape an error. The
recovery for container conflicts is apps_unstick + apps_deploy.
Already cleaned the 3 duplicate twenty-* services from prod
(kept twenty-live, freshest healthy). Frees ~9 GB RAM on the host.
Made-with: Cursor
Adds MCP tools so the AI can capture decisions, tasks, ideas, and the
vision in the moment instead of just reading them:
- plan_get read full plan for context
- plan_vision_set update vision when user refines their pitch
- plan_decision_log log a decision PROACTIVELY when one gets settled
(no permission ask) so the next session doesn't
re-litigate it
- plan_task_add track multi-step work or user-side follow-ups
- plan_task_complete mark done as we go
- plan_idea_add park stray ideas
System prompt is updated with a "be the user's scribe" section that
instructs the model to use these proactively with brief acks instead
of long confirmations.
Also reorders the Plan tab UI to: Vision · Tasks · Decisions · Ideas
(Ideas moved to bottom — it's the lowest-signal pile).
Made-with: Cursor
A new home for everything that happens BEFORE building:
- Vision — one-line elevator pitch (mirrors productVision)
- Ideas — the "park-it" bin for raw thoughts
- Tasks — what needs to happen next (open / done)
- Decisions — log of "we chose X over Y because Z"
Storage is appended under fs_projects.data.plan so no schema migration
is needed. CRUD lives at /api/projects/[projectId]/plan.
The bare project URL now redirects to /plan instead of /product, and
the AI chat receives decisions + open tasks in its active-project
context block — so it stops re-litigating settled questions and knows
what's queued up.
Made-with: Cursor
The chat panel now reads projectId from the URL and tags every thread
to it, so:
- Threads listed in the side panel are filtered to the project the user
is currently viewing (workspace-level chats still work from /projects).
- New conversations started from a project page are persisted with that
project_id, surviving page reloads.
- The system prompt prepends an ACTIVE PROJECT block so tool calls
(apps_create, devcontainer_ensure, etc.) use the right projectId
without the user having to name it.
- A small chip in the chat header shows which project the AI is
currently talking about.
Schema migrates idempotently on first request (project_id column +
composite index on fs_chat_threads).
Made-with: Cursor
Empty Infrastructure tabs were a wall of teaching text — every empty
category showed a dashed-border explainer button in the rail, the
right pane carried multi-paragraph explainer prose for every category,
and the LLM/Stripe/Storage/Secrets details all included instructional
prose for things the user hasn't done yet. On a brand-new project the
whole tab was 90% explainer.
Removed (everywhere):
- Per-category dashed empty-state buttons under each category
header. Empty categories now show just the header + "0", which
is enough — clicking the header still opens the details pane if
the user wants it.
- Right-pane Overview lead paragraph ("Infrastructure here is
auto-discovered…").
- CategoryDetail lead paragraph (def.label + def.blurb) and the
"Examples" chip row.
- CategoryDetail "Nothing detected yet — set a matching env var…"
info box. Replaced with a one-word "None yet." line.
- LLM CategoryDetail BYOK explainer box.
- DatabaseDetail "Expand this database in the left rail…" hint.
- DatabaseDetail "Set <KEY> on any app or service…" prose.
Renamed the section "How apps connect" → "Connection env" and
kept just the env-var snippet.
- StorageDetail unprovisioned-state explainer paragraph + "you can
also provision it now" info box. Replaced with a one-word
"No bucket provisioned yet." empty.
- StorageDetail "Vibn-bundled storage is GCS exposed via…" prose.
Kept the env snippet under a renamed "Connection env" section.
- ProviderDetail Stripe webhook hint box.
- ProviderDetail bottom "Values aren't shown here…" note.
- SecretsDetail "Values are never read on this surface…" note.
Kept (functional, non-prose):
- All KvRows and code snippets.
- Stripe "Connect Stripe (coming soon)" CTA.
- Storage "View workspace bucket" CTA.
- Provider dashboard external-link buttons.
- Edit / Rotate icon buttons on secrets (still disabled, tooltipped).
Cleaned up the now-unused Info icon import. The category def's
blurb / examples / dashboards fields stay in the source — they're
still useful as tooltip / search seed material for a follow-up
iteration that puts the teaching content somewhere less in-your-face
(like a help drawer).
Made-with: Cursor
CRITICAL: every Vibn project was rendering every other project's
services in the same workspace (Twenty CRM, n8n, all databases,
all secrets). Tenancy was effectively broken — cross-project data
exposure inside a workspace.
Root cause:
- Coolify's POST /projects validates `description` against a strict
allowlist (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`).
- Our description "Vibn project: <name> (workspace: <slug>)" contains
two colons. Every project-create on Coolify returned 422.
- lib/projects.ts caught that 422 and fell back to
`workspace.coolify_project_uuid` so deploys "weren't blocked."
- That UUID is shared by every Vibn project in the workspace, so
listServicesInProject(coolifyProjectUuid) returned the union of
all projects' services, applications, and databases for any
project in the workspace. The Product, Hosting, and Infrastructure
tabs all rendered cross-tenant data as if it were the current
project's.
Fixes (defense in depth — fix at every layer):
1. lib/coolify.ts createProject(): sanitize the description against
Coolify's allowlist at the boundary so no caller can ever ship
a description that 422s. Replaces disallowed chars with `-`,
collapses runs, caps at 255 chars.
2. lib/projects.ts ensureProjectCoolifyProject():
- Pre-sanitize the description we pass (belt + suspenders).
- Detect when `stored === workspace.coolify_project_uuid` (the
legacy bad state) and re-provision a dedicated project.
- REMOVE the workspace-UUID fallback on create failure. A 422
now leaves coolifyProjectUuid null and the UI shows an empty
state, which is correct: better to surface "no resources" than
to lie about which project owns what.
- Export sanitizeCoolifyDescription helper for reuse.
3. /api/projects/[projectId]/anatomy/route.ts: SELF-HEAL on every
read. If the project's stored Coolify UUID matches the
workspace's UUID, we treat it as missing, re-provision a
dedicated Coolify project on the fly (idempotent — reuses the
existing one if found by name), persist the new UUID, and
continue serving with the corrected scope. If provisioning
fails we fall back to undefined, NOT the workspace UUID, so
no cross-tenant data ever surfaces again.
The self-heal means existing already-broken projects will fix
themselves on the next page load — no manual data migration needed.
Made-with: Cursor
User can now click "Connect GitHub" inside the Import-existing-code
flow, sign in via GitHub, and pick a repo from a searchable list of
their own + collaborator + org repos. Both public and private repos
work — the encrypted access token on the user's account is auto-
attached when the create endpoint runs the agent-runner mirror.
OAuth flow:
- GET /api/integrations/github/connect — generates state, sets
a 10-min httpOnly cookie, 302s to GitHub authorize.
- GET /api/integrations/github/callback — verifies state,
exchanges code for token, fetches /user, encrypts the
token with secret-box (AES-256-GCM, VIBN_SECRETS_KEY) and
persists it on fs_users.data.integrations.github.
Bounces back to ?gh_connected=login or ?gh_error=msg.
- GET /api/integrations/github/repos — server-side fetches
the connected user's repos (per_page=100, sort=pushed,
affiliation=owner+collaborator+org_member). Returns the
GitHub login + a stripped repo summary; never the token.
- POST /api/integrations/github/disconnect — drops the integration
from fs_users (does NOT revoke on github.com).
Scopes requested: repo, read:user.
Token storage:
- Encrypted at rest with secret-box (lib/auth/secret-box.ts) using
VIBN_SECRETS_KEY. Tokens never leave the server.
- One token per fs_users row, keyed by email.
ImportSetup UI:
- On mount, fires /repos to detect connection state.
- If connected: shows a connected-as-@login chip with disconnect
link, a search-as-you-type repo picker (max 220px scroll, badges
for Private / language), and a "paste a different URL instead"
escape hatch.
- If not connected: shows a Connect GitHub card with a public-URL
fallback inline.
- On return from OAuth (?gh_connected=… or ?gh_error=…), surfaces
a toast and silently refreshes the repo list.
- Selected repo carries default_branch + repo id into the create
payload so we can store them on the project for later UI hints.
/api/projects/create:
- When a githubRepoUrl is mirrored, falls back to the user's
OAuth-linked token if no PAT is explicitly passed. Means the
flow "just works" for private repos once GitHub is connected.
Required env (already set in production):
- GITHUB_CLIENT_ID
- GITHUB_CLIENT_SECRET
Made-with: Cursor
"Myself / A client" was about who *owns* the project (a billing
concern), but at creation time we want to know who *uses* it — that's
what determines which Infrastructure providers we should pre-stage.
team = internal users (your team / employees)
→ SSO-style auth, no payments by default, simple roles
customers = external users (the public)
→ public sign-up + payments + transactional email by
default, custom domain matters
Both choices are reversible from the Infrastructure tab later — the
selector copy makes that explicit so users don't feel locked in.
Changes: - setup-shared: ForWhomSelector ("Myself" / "A client") replaced by
AudienceSelector ("My team" / "Customers"), with an "you can
change this later" hint underneath. New Audience union type
exported for the three setup screens to share.
- BuildSetup / OssSetup / ImportSetup: swap state + import + payload.
Defaults: BuildSetup → customers (most "vibe coder" projects are
public products), ImportSetup → customers (existing repos usually
are too), OssSetup → team (Twenty / n8n / Plausible style tools
are most often deployed for internal use).
- /api/projects/create: drop isForClient (we never read it
anywhere), persist audience as a first-class field on the
project record so the AI can branch on it during the first chat.
Made-with: Cursor
User feedback: the previous flow was a single-screen "name + audience"
dialog that gave AI no context about what the user actually wanted to
make. That worked for the demo but produced messy projects in practice
because everything was decided after the fact in chat.
The new flow asks the user one human question first ("How would you
like to begin?") and then captures the minimum context needed to seed
the AI's first conversation in the project.
Three paths, each is a 2-step setup screen with internal step dots:
- Build your own idea — Step 1: name + audience. Step 2: free-text
"what do you want to build". Becomes the project's vision and the
AI's first-message context.
- Run an open source tool — Step 1: name + audience. Step 2:
segmented tabs to either (a) paste a GitHub link or (b) describe
the kind of tool you want and have Vibn find one. Vision is set
to either "Install and host this open-source project: <url>" or
"Find and install an open-source tool that fits this need: <desc>"
so the AI knows which mode to operate in on first chat.
- Import existing code — Step 1: name + audience + repo URL.
Step 2: optional "what do you want to do with it" textarea.
Public repos only for v1; private-repo OAuth lands later.
Backend:
- /api/projects/create now accepts and persists `creationMode` and
`sourceData` on the project record under a `kickoff` blob:
{ mode, sourceData, vision, createdAt }
The chat endpoint will read this on first turn to seed the AI
with the user's stated intent rather than asking them to re-type
it in chat.
Cleanup:
- Removed FreshIdeaSetup, CodeImportSetup, ChatImportSetup,
MigrateSetup — replaced by BuildSetup, OssSetup, ImportSetup.
- Removed the unused initialWorkspacePath prop from
project-association-prompt (the new flow doesn't take it).
- TypeSelector defaults are restored — the modal opens on the
type-picker step now, not directly on a setup form.
UI building blocks added to setup-shared:
- TextArea (multi-line input)
- StepDots (page indicator)
- SegmentedTabs (generic-typed tab selector, used in OSS Step 2)
- SecondaryButton (used as ← Back inside Step 2)
Made-with: Cursor
Coolify exposes a single `postgres_db` per database resource (usually
"postgres"), but the cluster typically holds more than one db inside.
Twenty CRM connects to `default`; our prior query connected to
`postgres` and so reported the database as empty even when Twenty had
hundreds of tables.
Fix:
- pgListDatabases() enumerates every non-template, connectable db in
the cluster (`SELECT datname FROM pg_database WHERE datistemplate
= false AND datallowconn = true`).
- pgListTables() now unions table listings across all of them.
Schema is stamped as `<db>.<schema>` only when there's more than
one db, so single-db clusters keep the bare `public` flatten in
the UI.
- pgPreviewTable() understands the dotted `db.schema` form and
routes the preview `psql` invocation to the correct database.
Identifier whitelist applied to all three components (db, schema,
table) before splicing into SQL.
Hard caps unchanged (50 tables total, 8s SSH wall-clock).
Made-with: Cursor
UX rework after iteration with the user:
- Drop SMS, Analytics, Search, Monitoring categories from the rail.
They were detection-only with no first-class UX behind them; surface
is cleaner without them and they can return when each gets real
flows (auth-style "edit configurables", payment-style "connect").
- Storage no longer tries to detect S3/R2/GCS env vars. Instead it
surfaces the workspace's bundled Vibn-provisioned GCS bucket
(S3-compatible HMAC), with status, region, access id, and a
one-shot env snippet for app config.
- Email category no longer mixes in SMS providers.
- LLM renamed to "Models"; empty state mentions BYOK as upcoming.
- Payments empty state has a "Connect Stripe (coming soon)" CTA;
Stripe detail surfaces the webhook URL guidance.
- Secrets detail now lists actual env-var key names per resource,
grouped by detected provider (Stripe block, OpenAI block, etc.)
with an "Other (project-defined)" catch-all. Each row has Edit +
Rotate icon buttons (currently disabled with tooltips — wire-up
to apps.envs.upsert / services.envs.upsert lands in iter 2).
Live database inspection (Postgres only for now):
- New /api/projects/[id]/databases/[uuid]/tables — auth-scoped, lists
user-tables across non-system schemas via SSH-exec into the
database container's psql. Hard caps: 50 tables, 8s timeout, no
mutating queries possible (only SELECT row_to_json with LIMIT).
- New /api/projects/[id]/databases/[uuid]/preview — returns first 50
rows of a single table. Identifiers locked to /[A-Za-z0-9_]+/ so
splicing them into the SELECT is safe.
- DatabaseTableTree (lazy-fetch, schema-grouped, public-flat,
approximate row counts from pg_class.reltuples) and TableViewer
(sticky-header data grid, zebra rows, per-cell ellipsis at 360px).
- Fix in lib/coolify.ts: listDatabasesInProject was flattening every
db endpoint array (postgresqls, redises, mongodbs…) without
tagging the output rows with the engine. Every consumer was
seeing type=undefined which then bucketed as "unknown" and
blocked the table inspector. Now we tag at the flatten step so
every CoolifyDatabase has a stable type.
- Infrastructure tab: database tile is now expandable inline like
Codebases on Product. Auto-expands the first DB; click any table
to preview rows on the right.
Made-with: Cursor
Three sub-areas, all real, no static placeholders:
Databases — listDatabasesInProject(coolifyProjectUuid). Type is
normalised (postgresql / redis / mongodb / mysql / keydb
/ dragonfly / clickhouse) so the tile subtitle is stable
regardless of how Coolify spells the engine.
Providers — auto-detected from env-var keys across every app + service
in the project. 35+ patterns covering Auth (Clerk, Auth0,
Supabase, NextAuth, SuperTokens, WorkOS, Firebase Auth),
Email (Resend, Mailgun, Postmark, SendGrid, SES, Loops),
SMS (Twilio, Vonage), Payments (Stripe, LemonSqueezy,
Paddle), Analytics (PostHog, Mixpanel, Amplitude, Plausible,
Umami), LLM (OpenAI, Anthropic, Google AI, Mistral, Cohere,
Groq, OpenRouter), Storage (S3, R2, GCS, Supabase),
Search (Algolia, Meilisearch, Typesense), Monitoring
(Sentry, Datadog, LogSnag). Each tile drills down to show
which app/service the keys live in and which keys matched.
Secrets — env-var totals per app/service, sorted by count. Values
are never read or returned from this surface — keys only.
The detail pane explains how to read/edit (via AI chat
with services.envs.* / apps.envs.* MCP tools).
Anatomy endpoint extended in the same single-fetch shape: env vars are
loaded once, then both detectProviders() and summariseSecrets() run
against that one source so we don't double-fetch.
The static What-lives-here grid is gone — every tile shown corresponds
to something that actually exists in the project.
Made-with: Cursor
Anatomy + UI rewrite — locked the conceptual model after user feedback:
Product = "what makes up the thing you're shipping":
- Codebases (Gitea repos)
- Images (Coolify services backed by upstream Docker images: Twenty
CRM, n8n, etc.)
- Dev containers no longer surface here. The vibn-dev-* container is
the AI's workshop, not a product surface; previews it serves still
appear under Hosting → Previews.
Hosting = "where it lives + how it gets there", unified:
- Live: every running endpoint as one list. Each item carries a
source badge ("repo" | "image"), status dot, attached domain, and
last-build summary inline. No separate Build, Domains or Services
categories — those are properties on each Live item.
- Previews: dev container preview URLs (unchanged).
Anatomy endpoint reshaped accordingly:
- product.{codebases, images}
- hosting.{live, previews} (was production/services/previewUrls/domains)
- lastBuild summary fetched per repo-app via listApplicationDeployments
in parallel.
ProjectStagePill rewired to derive Live/Down/Building from hosting.live
+ hosting.previews. dev-container-detail.tsx removed.
services.* MCP tools added so AI agents can manage Coolify services
(Twenty CRM, n8n, …) the same way they manage apps:
- services.list, services.get
- services.start, services.stop
- services.envs.list, services.envs.upsert
All tenant-scoped via getServiceInWorkspace + getOwnedCoolifyProjectUuids.
vibn-dev-* containers stay hidden from services.list.
Made-with: Cursor
The vibn-dev-* services that the AI authors code in conceptually
belong to Product (build surface), not Hosting (runtime + reach).
Anatomy endpoint now splits Coolify services by name prefix:
- vibn-dev-* → product.devContainers[]
- everything else → hosting.services[]
Product tab gains a "Workspace" section above the codebases stack
with a single dev-container tile. Selecting it shows status +
active dev servers in the right pane. Codebase + file selection
behaves the same as before.
Hosting tab restructured from a stack of always-visible cards to
the same tile-rail pattern Product uses: left rail has 4 always-
present categories (Production / Services / Previews / Domains)
each with a count badge, items inside are clickable tiles, right
pane shows details for the selected item. Empty categories show a
one-liner explaining what would appear there — teaches the user
the model on a brand-new project without being preachy.
Made-with: Cursor
Adds GET /api/projects/[id]/anatomy returning the full project shape
in one shot — codebases (Gitea), production apps (Coolify
applications matched by repo URL), dev services (Coolify services in
the project's coolifyProjectUuid), preview URLs (active fs_dev_servers
rows), and aggregated domains. Each tab reads its own slice via the
new useAnatomy() hook so the page never fans out 3+ requests.
Hosting tab is now real: surfaces production / dev services / preview
URLs / domains with empty-state CTAs explaining what each means and
why it's empty when applicable. Includes a banner when nothing at all
is deployed for the project.
Project header pill (previously hard-coded from data.status, which
historically lied) now derives stage from hosting reality:
- any production app running → Live (green)
- any failed app → Down (red)
- any service / preview → Building (blue)
- else → fallback to data.status
Product tab refactored onto the same useAnatomy hook so we no longer
maintain two near-identical fetchers.
Made-with: Cursor
Adds a 10s timeout + AbortController to the codebases fetch and
parses error bodies defensively (so a non-JSON 4xx/5xx still shows
a status code instead of disappearing). The Product tab will now
show a clear message ("Request timed out", "HTTP 401 Unauthorized",
etc.) instead of spinning forever when the API can't respond.
Made-with: Cursor
Each codebase becomes its own panel with a header and an expandable
Gitea file tree inside. Clicking any file selects it and renders its
content in the right-hand preview panel (monospaced; no syntax
highlight yet). Single-codebase projects auto-expand the only
codebase on load so the tree is visible immediately.
Tree leaves are now interactive when an onSelectFile callback is
provided; selected rows highlight subtly so the user can tell where
the right pane's content came from.
Made-with: Cursor
Adds GET /api/projects/[id]/codebases that inspects the project's
Gitea repo:
- apps/* present → one codebase per subdir (Turborepo)
- else → single codebase rooted at the repo root
- no repo → empty list with reason="no_repo"
Product tab now fetches this list, picks the first as the default
selection, and surfaces explicit loading / error / empty states
(previously it hung on "Loading…" when apps/web 404'd in single-
repo projects).
Made-with: Cursor
Replaces the old two-tile project landing with a tabbed shell anchored
on three sections: Product (codebases), Infrastructure (swappable
services), Hosting (runtime + reachability). Bare project URL
redirects to /product so the founder always lands on the most
actionable surface.
Product tab is the only one wired with real data so far: each
codebase tile is selectable and renders a lazy-loading Gitea file
tree for apps/<codebase>/ in the right column. Both columns share
height + a heading slot so panels stay visually aligned even when
the right side is sparse.
Infrastructure and Hosting are stubs ready for Phase 2 wiring (no
behavioural change vs today). The old (workspace)/infrastructure
route is removed in favour of the new tab; the other 15 sidebar
routes are untouched and still reachable for the migration window.
Made-with: Cursor
The current prompt reads like a runbook — operationally correct, but
it produces tool-call orchestrators rather than co-founders. Now that
the thinking pill streams reasoning between tool calls, the chat
bubble should be where opinion + judgment + push-back live.
What changed:
1. New "Voice" section right after the role declaration. Tells the
model to:
- Stop narrating intent before tool calls (the thinking pill
already covers this).
- Pack post-tool summaries with the actual answer + obvious next
step, not a recap of which tools ran.
- Have an opinion. Pick Postgres or Mongo, defend in one sentence,
proceed. Don't bullet pros/cons unless asked.
- Push back when it matters. Refuse "deploy without backups",
suggest Pipedream over n8n if it fits better.
- Surface adjacent risks unprompted (missing env vars, DNS not
propagated, autosave overdue) — the model is protecting the
user's work because the user trusts it to.
- Honest about uncertainty: "I'm not sure but X" beats false
confidence.
- Length matches stakes — short for short Qs, paragraph for big
decisions; never pad, never truncate.
- Markdown sparingly: backticks always for paths/IDs/URLs;
headings only when 3+ sections; otherwise prose.
2. Hard rules tightened:
- "Infer projectId from context, only ask if genuinely ambiguous"
replaces the rote "ask once, then proceed" — saves a tool round
and feels less robotic.
- Added explicit "ship/apps.deploy result is authoritative — don't
verify with gitea_* or shell_exec" rule. Reinforces the fix from
a896d07 at the prompt level so even older Gemini instances pick
it up.
- Added "don't loop blindly on tool errors" — if shell_exec fails
twice, surface and ask. Prevents the 12-tool retry chains from
earlier.
- Removed redundant "be concise" + "summarize after every tool
call" — both are now subsumed by the Voice section's richer
guidance.
Operational middle (Vibn structure, deploy recipes, dev container
workflow, port slot rules, HMR config, troubleshooting) is unchanged.
Those are the guard rails that make Path B work.
Net length: +650 chars on a ~8k-char prompt. Worth it for the voice
shift.
Made-with: Cursor
Today the chat shows ✓-icon tool trays with no narration between
calls — the user has no idea WHY the AI just called fs_edit or
ship. Meanwhile Gemini is producing 500-1000 chars of first-person
reasoning per round ("Updating the Express Server: A Quick
Production Deployment / Right, so we have a basic Express server
here, nothing fancy. I need to get a new version live...") and
billing us for those tokens — we just weren't asking for them.
Three layers:
1. lib/ai/gemini-chat.ts
- generationConfig.thinkingConfig.includeThoughts = true (default
true, opt-out via includeThoughts: false). We're already paying
for thinking tokens regardless of this flag — it just controls
whether the model returns the human-readable summary or only the
compressed signature.
- callGeminiChat now returns { text, thoughts, toolCalls,
finishReason } and the parser splits parts by `part.thought`.
CRITICAL bug avoided: previously `if (part.text) text += ...`
would have lumped thoughts into the chat bubble verbatim.
- streamGeminiChat yields `{ type: 'thinking' }` for thought parts.
2. app/api/chat/route.ts
- New SSE event: `data: {"type":"thinking","text":"..."}`
- Emitted on every round alongside text + tool_start.
- Recovery-summary branch also emits thoughts so even when the
model produces no user-facing prose, the user sees the model's
reasoning instead of dead silence.
3. components/vibn-chat/chat-panel.tsx
- Message gains optional `thoughts` field (in-memory only — we do
NOT persist thoughts to fs_chat_messages; they're ephemeral and
cheap to drop).
- New ThinkingBubble component: dashed-border italic pill above
the assistant bubble, collapsed by default to show one-line
preview, click to expand for full chain. Strips Gemini's
"**Section Heading**" prefixes from the preview.
- SSE handler accumulates thinking chunks onto the in-flight
assistant message.
UX impact: instead of staring at fs.read ✓ fs.edit ✓ ship ✓ icons,
the user sees "Examining the target server file..." → "Shipping the
twenty-crm project..." in real time. Costs zero additional tokens
(we already paid for the thoughts).
Cleanup: removed scripts/probe-gemini-raw.ts and
scripts/probe-recovery-summary.ts — diagnostic scripts that
identified this opportunity, no longer needed in-tree.
Made-with: Cursor
Standard chat-app pattern: while the AI is streaming or running
tools, the Send button morphs into a Stop control (filled square
inside a faded spinner). Click it (or press Esc) to abort the turn.
Why: with MAX_TOOL_ROUNDS=18, a confused tool-loop can chew through
60-90s of compute and tokens. The user had no way to interrupt — they
just watched ✓ icons accumulate. Stop fixes that.
How:
Client (chat-panel.tsx):
- abortRef holds the in-flight AbortController; lives in a ref so the
Stop button can reach it without re-rendering on every chunk.
- sendMessage creates a fresh controller and passes signal to fetch.
- cancelMessage calls .abort(); also bound to Escape while sending.
- Button morph: while `sending`, render lucide Square overlaid on a
faded Loader2 spin, switch onClick to cancelMessage, swap aria/title
to "Stop generating (Esc)".
- Catch DOMException AbortError separately from network errors and
append "(stopped by user)" to the partial assistant message.
- Textarea no longer disabled during streaming so users can queue
the next prompt; Enter still won't submit until the turn ends.
Server (app/api/chat/route.ts):
- request.signal is captured before the ReadableStream and an `aborted`
flag is flipped on the addEventListener('abort', ...) callback.
- Loop checkpoints `aborted` (a) at the top of every round, (b) before
the inner tool-call loop, (c) before each individual executeMcpTool
call. Picks the next safe boundary instead of yanking mid-call.
- On abort: emit a "(stopped by user)" text chunk + an "aborted" event,
skip the round-cap recovery summary (don't pay for tokens the user
just canceled), persist the partial assistant message normally.
- Fetch errors that come from the abort propagating into Gemini's HTTP
client are recognized and downgraded from "error" to "aborted".
- safeClose() guards against double controller.close() when the abort
races with normal completion.
Made-with: Cursor
After "ship" succeeded the AI was burning 7+ follow-up tool calls
(gitea_repos_list, gitea_credentials, shell.exec×4, apps_list) trying
to verify what actually got pushed and where it deployed. That ate
through MAX_TOOL_ROUNDS and the user got tool-icon spam with no
narrative summary.
Three fixes:
1. ship now returns commitSha (parsed from `git rev-parse HEAD`),
giteaCommitUrl, giteaBranchUrl, coolifyDeployUrl, coolifyAppUuid,
and a summaryHint string telling the AI exactly what to say next.
2. ship's tool description now explicitly tells Gemini "do NOT call
gitea_*, shell_exec, or apps_* afterwards to verify — the result
is authoritative."
3. MAX_TOOL_ROUNDS 12 → 18 as a safety net for genuinely long chains.
Net effect: ship goes from ~12 tool calls to verify a deploy down to
1 (just ship itself), and the next text turn has the SHA + URL
inline.
Made-with: Cursor
Five focused improvements rolled into one deploy:
1. Pre-allocated preview ports + Traefik labels.
Bake docker labels for ports 3000-3009 into every dev-container
compose at ensureDevContainer() time. Each port has its own
subdomain: preview-<slot>-<projectSlug>-<token>.preview.vibnai.com.
Token is derived from projectId so URLs are stable across restarts
but not enumerable across projects. Joins the coolify external
network so Traefik can reach the container.
This avoids the runtime compose-mutation approach (which would
have required a Coolify redeploy on every dev_server.start, ~30s
latency). The trade-off is a hard cap of 10 concurrent dev servers
per project — fine for the "frontend + API" scenario, the only one
we can practically envision.
Wildcard DNS + Traefik DNS-01 cert remain a manual one-time setup
(see vibn-dev/PREVIEWS.md).
2. dev_server.start: port-collision handling.
Detect listeners via `ss` + `lsof` before launching. Three outcomes:
- port out of slot range → PortOutOfRangeError → 400 with allowedRange
- port owned by a different process → PortBusyError → 409
- port owned by a tracked vibn dev server (same project) → kill
the stale row and reuse the slot (most-recent-write-wins; matches
AI mental model when it does an edit-restart loop)
Surfaced via dedicated MCP error codes so the AI can recover
intelligently instead of looping the same start call.
3. gitea_file_{read,write,delete}: hard-removed from AI tool list.
These tools competed with fs.* and tempted the AI into the slow
path. Pulled from VIBN_TOOL_DEFINITIONS but kept in the MCP
dispatcher for 30 days for any external clients still using them.
System prompt rewritten to make Path B the only documented way to
author code; gitea_repo_* + gitea_branches_* remain because they
handle one-time orchestration with no fs.* equivalent.
4. System prompt: HMR + preview-port discipline.
New section covering Vite HMR (clientPort:443 wss), Next dev
(-H 0.0.0.0), and Express (HOST=0.0.0.0). Explicit "ports must be
3000-3009" rule + "if PORT_BUSY don't blindly retry" guidance.
5. Cron docs (vibn-dev/CRON.md).
/etc/cron.d/vibn-path-b template + smoke commands for autosave
and idle-sweep. Wires both 5-minute jobs that already have admin
endpoints (POST /api/admin/path-b/{autosave,idle-sweep}).
MCP version bump 2.6.0 -> 2.7.0. Smoke test: 65 tool defs (down from
68 after gitea_file_* removal), all accepted by Gemini.
Made-with: Cursor