DEV Community

Cover image for I Finished What I Started: Adding AI to Every Layer of a Form Builder (With GitHub Copilot)
Natheesh Kumar
Natheesh Kumar

Posted on

I Finished What I Started: Adding AI to Every Layer of a Form Builder (With GitHub Copilot)

GitHub “Finish-Up-A-Thon” Challenge Submission

This is a submission for the GitHub Finish-Up-A-Thon Challenge


What I Built

Dculus Forms is a full-stack, multi-tenant SaaS form builder — think Typeform, but with real-time collaborative editing (Y.js), a plugin system, analytics, and multi-cloud deployment.

I started this project with the core builder working: drag-and-drop fields, a public form viewer, GraphQL API, Prisma + PostgreSQL. But several important layers were stubbed out or incomplete:

  • The AI form builder chat existed but had stability problems: schema fetched fresh on every message, unbounded history, no user feedback while the AI was thinking, and no way to undo a bad AI edit.
  • The analytics dashboard showed numbers but gave users no guidance on what to do about them.
  • Dashboard stats had three hardcoded trend badges (+12%, +8%, +15%) that were never connected to real data.
  • The settings page was a double-nested tab maze.

The GitHub Finish-Up-A-Thon gave me the forcing function to finally ship these. Here's exactly what the before/after looks like — and how GitHub Copilot helped me get there faster than I thought possible.


The "Before" State

AI Chat: Working but Fragile

The form builder had an AIEditDrawer — a chat panel where users could type "add a required email field after Full Name" and the AI would apply it. Under the hood: a Vercel AI SDK ToolLoopAgent with 11 tools streaming over HTTP to a useChat hook.

Problems I'd deferred:

  • Schema re-fetched from Y.js on every single message — every turn read the full collaborative document regardless of whether anything had changed.
  • Conversation history grew unbounded — a long session could eat thousands of tokens just in history replay.
  • Users saw generic bouncing dots — no indication of what tool the AI was calling, what it changed, or how many steps it had taken.
  • No undo for AI turns — if the AI made a bad batch edit, users had no targeted recovery path.
  • Destructive actions applied immediatelyremoveFields went straight to the Y.js store with no warning, no "this affects 47 existing responses" heads-up.
  • Token quota was invisible — users on the free plan (200k tokens/month) had no idea how close to the limit they were.

Analytics: Data Without Direction

The field analytics tab showed per-field fill rates, option frequency charts, average response lengths, and completion time percentiles. Rich data — but purely descriptive. A user seeing "your Country dropdown has a 31% fill rate" had to mentally translate that into action themselves.

Dashboard Stats: Hardcoded Lies

// Before — StatsGrid.tsx
<TrendBadge value={positiveTrend(12)} />  // always +12%
<TrendBadge value={positiveTrend(8)} />   // always +8%
<TrendBadge value={positiveTrend(15)} />  // always +15%
Enter fullscreen mode Exit fullscreen mode

These weren't placeholders with a TODO comment. They had shipped and sat there for months.

Settings: Double-Nested Tab Maze

The Settings page used tabs within tabs — you'd click Account, then click a sub-tab for Profile, then another for Organization. Three levels of navigation for simple tasks.


How GitHub Copilot Helped Me Get There

Before writing any feature code, I set up GitHub Copilot with domain-specific instruction files. This turned out to be the highest-leverage thing I did in the entire sprint.

The Instruction Files

I created 8 instruction files in .github/instructions/, each scoped to a part of the codebase:

.github/instructions/
├── authentication.instructions.md   # better-auth patterns
├── backend.instructions.md          # resolver → service → repository layering
├── database.instructions.md         # Prisma conventions, serialization rules
├── frontend.instructions.md         # import rules, component organization
├── graphql.instructions.md          # schema-first conventions, error codes
├── i18n.instructions.md             # translation requirements (en + ta)
├── shared-packages.instructions.md  # @dculus/ui, @dculus/types, @dculus/utils
└── testing.instructions.md          # Vitest, Cucumber BDD patterns
Enter fullscreen mode Exit fullscreen mode

Each file has an applyTo frontmatter that scopes it automatically. For example:

---
applyTo: "apps/form-app/**,apps/form-viewer/**,apps/admin-app/**"
---
Enter fullscreen mode Exit fullscreen mode

What this meant in practice: When I asked Copilot to "add a GraphQL query for field insights," it automatically:

  • Used createGraphQLError with typed GRAPHQL_ERROR_CODES (not throw new Error())
  • Called requireAuth + requireOrganizationMembership in the resolver
  • Used @dculus/types for type imports, not local relative paths
  • Added Tamil translations alongside English ones

Without the instruction files, I'd have been correcting import paths and missing auth guards on every single file. With them, the first draft was almost always structurally correct.

Custom Agents for Complex Work

For multi-file features, I used Copilot's custom agent mode with specialized agents I'd defined:

  • feature-developer — full-stack features across GraphQL schema, resolver, service, and frontend
  • field-type-developer — covers all 10 layers a new field type touches (types, validation, renderer, builder UI, analytics, filters, export, collaboration, i18n)

When I was building AI Field Insights (a feature that spans Prisma schema, a new backend service, 2 GraphQL resolver additions, 5 frontend components, and translations), I used the feature-developer agent with a detailed spec. Copilot produced a complete plan with a file map and step-by-step tasks, each with failing tests first. I reviewed, accepted the plan, and executed it task by task.

The back-and-forth that would have taken me a day of context-switching took a focused afternoon.


What I Finished

1. AI Chat — Stability + Full Feature Overhaul

Schema cache with 10-second TTL:
Instead of reading the Y.js document on every message, the route now caches the serialized form schema per formId. A /api/ai/invalidate-schema endpoint is called by applyAIOp whenever a mutation is applied — so the cache is always fresh when the next message arrives.

// After — aiChat.ts (simplified)
const schemaCache = new Map<string, { schema: FormSchema; cachedAt: number }>();
const SCHEMA_CACHE_TTL_MS = 10_000;

export function getFormSchema(formId: string): FormSchema | null {
  const entry = schemaCache.get(formId);
  if (!entry) return null;
  if (Date.now() - entry.cachedAt > SCHEMA_CACHE_TTL_MS) {
    schemaCache.delete(formId);
    return null;
  }
  return entry.schema;
}
Enter fullscreen mode Exit fullscreen mode

20-message history cap:

// After — aiChatService.ts
const MAX_HISTORY_MESSAGES = 20;
const rows = await prisma.aIChatMessage.findMany({
  where: { conversationId },
  orderBy: { createdAt: 'desc' },
  take: MAX_HISTORY_MESSAGES,
});
return rows.reverse();
Enter fullscreen mode Exit fullscreen mode

Token meter — users can now see their quota:

// AITokenMeter.tsx — rendered at the bottom of every AIEditDrawer
<AITokenMeter organizationId={organizationId} />
// Shows: [████████░░] 1.4M / 2M tokens used (Starter plan)
Enter fullscreen mode Exit fullscreen mode

ChangeSummaryCard — what actually changed this turn:
Every AI response now includes a structured diff card showing added/modified/removed fields, so users don't have to visually scan the entire form to see what the AI did.

Propose-then-confirm for destructive operations:
removeFields, removePage, and proposeFieldTypeChange no longer apply immediately. They return a proposal card showing exactly how many existing responses would be affected:

⚠️  Remove "Country" (select)?
    This field has 312 existing responses.
    [Cancel]  [Confirm Delete]
Enter fullscreen mode Exit fullscreen mode

Field type conversion done right:
Converting a text field to a select isn't a mutation — it's delete-old + create-new with a new ID. This preserves response history integrity (old responses keep their text values; the new field starts clean). Copilot caught this edge case when I described the feature: it flagged that mutating type in place would corrupt the data: { [fieldId]: value } structure that analytics and exports rely on.

Context-aware suggestion chips (useAIChips):
The chat drawer now shows smart chips based on the current state — "Add validation", "Reorder fields", "Add a page" — derived from the form's actual content, not hardcoded strings.

Net result: 14 tools total (up from 11), all with TypeScript Zod schemas validated at runtime, streamed via DefaultChatTransport.


2. AI Field Insights — Analytics That Explains Itself

This is the feature I'm most proud of, because it closes the loop between seeing a problem and fixing it without leaving the page.

How it works:

  1. User opens Form Analytics → Field Analytics.
  2. No tips yet — a "✨ Analyze all fields" button appears.
  3. One click triggers a single batch AI call (1–3k tokens). The AI receives all field analytics data and returns a structured insight per field.
  4. Tips are persisted in a new AIFieldInsight Prisma model, keyed by (formId, fieldId) with a schemaHash for staleness detection.
  5. Future visits read from the database — zero token cost until the form schema changes.
model AIFieldInsight {
  id           String   @id @default(cuid())
  formId       String
  fieldId      String
  tip          String   @db.Text      // "67% of users skip this field. Consider making it optional."
  fixPrompt    String   @db.Text      // exact message to pre-fill in AIEditDrawer
  severity     String                 // "warning" | "error" | "success" | "info"
  schemaHash   String                 // 16-char SHA-256 prefix — staleness detection
  generatedAt  DateTime @default(now())

  form Form @relation(fields: [formId], references: [id], onDelete: Cascade)

  @@unique([formId, fieldId])
  @@index([formId])
}
Enter fullscreen mode Exit fullscreen mode

The "Fix with AI ✦" button:
Each insight card has a button that opens the AIEditDrawer with the fixPrompt pre-loaded as the initial message. The user clicks confirm, and the AI applies the suggested fix immediately. The gap between insight and action is one click.

Stale detection:
If the form schema changes after analysis, a yellow banner appears: "Form changed since last analysis — Re-analyze?" This is powered by comparing a 16-char SHA-256 prefix of the field structure against the stored schemaHash.


3. Real Trend Percentages — Goodbye Hardcoded Lies

Before:

<TrendBadge value={positiveTrend(12)} />  // +12%  fiction
<TrendBadge value={positiveTrend(8)} />   // +8%   fiction
<TrendBadge value={positiveTrend(15)} />  // +15%  fiction
Enter fullscreen mode Exit fullscreen mode

After — GraphQL schema:

type FormDashboardStats {
  responsesToday: Int!
  responsesThisWeek: Int!
  responsesThisMonth: Int!
  trendResponsesToday: Float   # % change today vs yesterday; null = no data
  trendThisWeek: Float         # % change this week vs last week; null = no data
  trendResponseRate: Float     # percentage-point delta; null = <10 views
}
Enter fullscreen mode Exit fullscreen mode

The resolver runs 4 parallel Prisma queries and computes real period-over-period deltas. null means insufficient data — the badge simply hides instead of showing a misleading number. A form with 3 total responses doesn't pretend it grew by 12%.


4. Settings Redesign — Typeform-Style Sidebar

Replaced double-nested tabs with a clean left sidebar following a pattern users already know from tools like Typeform and Linear:

Settings
├── Account
│   └── Profile          ← name, email, avatar upload
└── Organization
    ├── Members           ← invite, role management
    └── Billing & Plans   ← Chargebee subscription dashboard
Enter fullscreen mode Exit fullscreen mode

Old URLs (/settings/account, /settings/team) redirect to new equivalents — no broken links for existing users.


The Copilot Moment That Surprised Me Most

When building the field type conversion feature, I described the problem to Copilot: "convert a text field to a select field." Its first response wasn't code — it was a question about data integrity:

"If you mutate the type in place and keep the same field ID, responses stored as data: { [fieldId]: "some text" } will be read by the select renderer expecting an option value. Do you want conversion to create a new field ID so old responses are preserved as immutable history?"

That's the kind of thing I would have caught in a code review — after a bug report from a user. Copilot caught it during design.

The instruction files were doing their job: Copilot knew that response.data is JSONB keyed by fieldId, that analytics and exports all read from that structure, and that changing a field's type mid-life would silently corrupt historical data. It had the full mental model of the system because I'd written it down.


Tech Stack

Layer Technology
Frontend React 18, Vite, TypeScript, Tailwind CSS, shadcn/ui
AI Vercel AI SDK v5, Azure OpenAI
State Apollo Client (server), Zustand slices (local)
Realtime Y.js + Hocuspocus WebSocket
Backend Express.js, Apollo Server (GraphQL SDL)
Database PostgreSQL + Prisma ORM
Auth better-auth (cookie + bearer, org plugin)
Billing Chargebee
Testing Vitest (unit), Playwright + Cucumber (E2E)
Deployment Azure Container Apps (backend) + Cloudflare Pages (frontend)

GitHub Repository

github.com/dculussoftwares/dculus-forms

The .github/instructions/ folder, custom agents, and skills are all in the repo — feel free to use the pattern for your own project.



Demo URL:

What I Learned

Write the context down first, then write the code. The 8 instruction files took about 2 hours to write and saved me 10x that in correction loops. Every time Copilot produced something architecturally wrong without them, it was because I hadn't told it how this specific codebase works.

Specs as prompts. I wrote design specs in docs/superpowers/specs/ before writing a line of code. These double as context for Copilot — feeding it a spec file produces dramatically better first drafts than "hey, build me a thing."

The schema cache story is a Copilot story. I described the performance problem (schema read on every message). Copilot suggested the TTL cache + invalidation endpoint pattern, explained why a push invalidation is better than a TTL-only approach for a real-time collaborative document, and wrote the first draft. I reviewed it, tweaked the TTL, and shipped it.


Built with GitHub Copilot. Finished for the GitHub Finish-Up-A-Thon Challenge.

Top comments (0)