AGENTS.md - Working in the ai_app_challenge Repository
This document provides essential context for AI agents and developers working on this project.
Project Overview
A Student Readiness Inventory survey application built entirely in Gleam:
-
Backend project (
backend/gleam.toml): Erlang target, runs on BEAM with Wisp web framework- Generates static HTML at build-time from
questions.txt - Serves pre-generated HTML files
- Receives form submissions and logs to terminal
- Generates static HTML at build-time from
-
Root project (
gleam.tomlat root): Shared utilities (JavaScript target for tests)- Contains
questions_parser.gleamused by backend - Minimal - mostly for code organization
- Contains
Key Feature: Survey form is generated once at build-time from questions.txt using Lustre elements, producing static HTML files. No dynamic template rendering at runtime.
Essential Commands
Root Project (Shared utilities)
# From C:/Users/dougl/coding/ai_app_challenge/
gleam build # Compile shared modules
gleam test # Run tests (uses gleeunit)
gleam format --check src test # Check formatting
Backend Project
# From C:/Users/dougl/coding/ai_app_challenge/backend/
gleam deps download # Download dependencies
gleam build # Compile Erlang code
gleam run # Start the HTTP server on port 8000
gleam test # Run tests
gleam run -m build # Generate static HTML from questions.txt
Build & Deploy
# From repository root (Windows PowerShell)
.\scripts\build_frontend.ps1
This script:
- Generates static HTML: Reads
questions.txt, runsgleam run -m buildin backend- Generates
backend/priv/static/survey.htmlwith all questions pre-rendered - Generates
backend/priv/static/thanks.html
- Generates
Must be run before starting the backend for the first time or after changing questions.txt.
CI/Verification
The GitHub Actions workflow (.github/workflows/test.yml) runs:
gleam deps downloadgleam testgleam format --check src test
OTP version: 28, Gleam version: 1.15.2
Project Structure
ai_app_challenge/
├── src/ # Root project source (shared utilities)
│ ├── ai_app_challenge.gleam # Shared modules
│ └── questions_parser.gleam # Question parsing utility (used by backend)
├── test/
│ └── ai_app_challenge_test.gleam # Tests using gleeunit
├── backend/ # Backend project (main application)
│ ├── src/
│ │ ├── ai_app_challenge_backend.gleam # Main entry
│ │ ├── backend_app.gleam # Server bootstrap & port setup
│ │ ├── build.gleam # Build-time HTML generation from questions.txt
│ │ ├── graceful_shutdown.gleam # Erlang FFI for OTP shutdown
│ │ ├── questions_parser.gleam # Question parsing (mirrors root/src/)
│ │ └── survey/
│ │ ├── router.gleam # HTTP request routing
│ │ ├── handlers.gleam # Route handlers (GET /survey, POST /api/survey)
│ │ └── schema.gleam # Form decoding & validation
│ ├── priv/static/ # Generated static files
│ │ ├── survey.html # Pre-generated from questions.txt (build-time)
│ │ └── thanks.html # Pre-generated thank you page
│ └── manifest.toml # Dependency lock file
├── scripts/
│ └── build_frontend.ps1 # Build script: generates HTML from questions.txt
├── questions.txt # Single source of truth for survey questions
├── gleam.toml # Root project config
└── manifest.toml # Root project dependency lock
Application Architecture
Build-Time HTML Generation
questions.txt
↓
questions_parser.parse() // Produces List(Question)
↓
render_survey_form() // Creates Lustre Element tree
↓
element.to_document_string() // Converts to HTML string
↓
simplifile.write() // Writes to backend/priv/static/survey.html
HTTP Request Handling
-
Routing (
backend/src/survey/router.gleam):GET /→ redirect to/surveyGET /survey→ serves pre-generatedpriv/static/survey.htmlGET /survey/thanks→ serves pre-generatedpriv/static/thanks.htmlGET /favicon.ico→ 204 No ContentGET /__quit?token=<token>→ graceful shutdownPOST /api/survey→ form submission handler
-
Survey Form (
backend/priv/static/survey.html, generated at build time):- Pure HTML form with all questions pre-rendered
- No client-side generation or dynamic content
- Submits as HTTP POST to
/api/survey - Inputs are named by question ID (e.g.,
q1_confidence,q2_time_management)
-
Form Submission (
backend/src/survey/handlers.gleam→post_survey()):- Extracts form values from
application/x-www-form-urlencodedbody - Calls
schema.decode()to parse intoSubmissionrecord - Logs to stdout: name, email, grade, answers (dict), notes
- Redirects to
/survey/thankson success, or returns 400 on decode error
- Extracts form values from
-
Data Model (
backend/src/survey/schema.gleam):Submissionrecord: student name, email, grade, answers (Dict of question_id→Int score), notesDecodeErrortype:MissingField,InvalidInt- Validation: name, email, grade are required; answers and notes are optional
Data Flow
USER ACTION: RUNTIME:
│
GET /survey handlers.get_survey()
│ ↓
│ simplifile.read("priv/static/survey.html")
│ ↓
└─→ [Browser receives pre-generated HTML with all questions]
User fills form and clicks Submit
│
POST /api/survey handlers.post_survey()
│ ↓
│ schema.decode(form.values)
│ ↓
│ io.println(submission)
│ ↓
└─→ 302 Redirect to /survey/thanks
GET /survey/thanks handlers.get_thanks()
│
└─→ [Browser receives pre-generated thanks page]
Key Architectural Decisions & Gotchas
1. Single Source of Truth: questions.txt
All questions come from questions.txt in the repository root. Format:
q1_confidence
I feel confident about my ability to succeed in my classes.
1 = Strongly disagree, 5 = Strongly agree
likert_5
---
q2_time_management
I manage my time well and can keep up with deadlines.
1 = Strongly disagree, 5 = Strongly agree
likert_5
---
[more questions...]
At build time:
gleam run -m buildreads and parsesquestions.txtquestions_parser.parse()creates aList(Question)render_survey_form()builds Lustre elements from questionselement.to_document_string()converts to HTML string- HTML is written to
backend/priv/static/survey.html
To add/change questions:
- Edit
questions.txt - Run
.\scripts\build_frontend.ps1 - Restart backend (or just refresh browser if already running)
2. Build-Time HTML Generation (Zero Runtime Overhead)
- HTML is generated once during build (via
gleam run -m build), then served as static files - No rendering or template processing at request time
- Faster response times and better for caching
- Uses type-safe Lustre elements (not string concatenation)
- Important: If you change
questions.txt, you must re-run the build script before changes appear
3. Build-Time HTML with Lustre Elements
The HTML is generated using Lustre elements (type-safe, composable) rather than string concatenation.
Key functions in backend/src/build.gleam:
render_survey_form()- main form structure using Lustre elementsrender_question_element()- renders each questionrender_choice_element()- renders radio button optionsstyle_css()- survey page stylesthanks_style_css()- thanks page styles
Benefit: Type-safe HTML generation, easy to refactor, no manual HTML escaping needed.
4. Serving Static HTML Files
The backend serves pre-generated HTML files from backend/priv/static/:
survey.html- form with all questions (generated at build-time)thanks.html- thank you page (generated at build-time)
Handlers in backend/src/survey/handlers.gleam read these files from disk and return them. If files don't exist, returns 500 error with helpful message.
5. Graceful Shutdown Token
The backend prints a shutdown token and URL on startup:
Dev shutdown token: <random-string>
Quit URL: http://127.0.0.1:8000/__quit?token=<token>
This is dev-only. In production, this should be:
- Removed entirely, OR
- Replaced with environment-variable-based authentication
The token is hardcoded as a dev value in backend_app.gleam (line 11).
6. Form Validation
- Server side: Only
student_name,student_email,student_gradeare required; validated inschema.required() - Client side: HTML5
requiredattribute on form inputs - Email validation: Browser-level only (type="email")
- No backend email format validation
7. Questions Data Model
- Likert scale answers are stored as
Int(1–5) - Form submission just parses string values to integers
schema.decode()silently skips any answer that failsint.parse()- If a question isn't in the form submission, it's simply absent from the answers dict
- This is intentionally permissive (allows partial submissions)
8. Graceful Shutdown via Erlang FFI
graceful_shutdown.gleam uses an @external annotation to call Erlang's init:stop():
@external(erlang, "init", "stop")
fn init_stop() -> Nil
This is the idiomatic way to shut down an Erlang node gracefully.
9. No Persistence
Form submissions are logged to stdout only. There's no database, file storage, or any persistent backend.
Code Conventions & Patterns
Gleam Style
- Module names map to file paths:
survey/router.gleam→ modulesurvey/router - Type names are CamelCase; function names are snake_case
- Functions are organized by public API first, then helpers at the bottom
- Error handling uses
Resulttype extensively
Build Module Pattern
The build.gleam module demonstrates:
- Reading files at build time:
simplifile.read() - Parsing structured data:
questions_parser.parse() - Rendering HTML: Lustre elements (type-safe)
- Writing output files:
simplifile.write() - Handling errors and logging:
io.println()
Form Decoding Pattern
Example from schema.gleam:
required(dict, "field_name")
|> result.try(fn(value) {
// Chain further validation or decoding
// Use result.map or result.try
})
This is the standard Gleam pattern for chaining validations. Don't nest case statements; use result.try() and result.map().
HTML Rendering (Lustre Elements)
In build.gleam, HTML is generated using Lustre elements:
html.div([attribute.class("container")], [
html.h1([], [element.text("Title")]),
html.form(
[attribute.method("POST"), attribute.action("/api/survey")],
[
// form contents
]
)
])
|> element.to_document_string()
This approach is:
- Type-safe (compiler checks valid HTML structure)
- Composable (break into smaller functions)
- Automatic escaping (no manual HTML entity handling needed)
- Still produces minified HTML
Static Asset Serving
Wisp middleware pattern (from router.gleam):
use <- wisp.serve_static(req, under: "/assets", from: static_dir)
This is stacked middleware: each use block is applied in order. Earlier handlers take precedence.
Testing
Test Framework: gleeunit
import gleeunit
pub fn main() -> Nil {
gleeunit.main()
}
// Test functions must end with `_test`
pub fn my_test() {
assert 2 + 2 == 4
}
Current Tests
test/ai_app_challenge_test.gleam: Example test
Run Tests
gleam test
To Add More Tests
- Add
.gleamfiles intest/directory - Define functions ending in
_test - Use
assertor gleeunit matchers (see gleeunit docs) - No separate test runners needed;
gleam testauto-discovers
Note: Currently minimal test coverage. Consider adding tests for:
questions_parser.parse()with various inputsschema.decode()with valid and invalid form data
Dependencies & Versions
Root Project (Shared utilities)
gleam_stdlib >= 0.44.0– standard librarygleeunit >= 1.0.0(dev) – testing framework
Backend Project (Application)
gleam_stdlib >= 0.44.0gleam_erlang >= 1.0.0– Erlang VM bindings (process, atom, etc.)gleam_http >= 4.0.0– HTTP typesfilepath >= 1.1.0– path manipulationlogging >= 1.0.0– structured logging (currently unused)mist >= 6.0.0– HTTP serverwisp >= 2.2.0– web framework (routing, middleware)simplifile >= 3.0.0– file I/O (build-time and runtime file reading)lustre >= 5.6.0– HTML element generation (build-time only, for type-safe HTML)gleeunit >= 1.0.0(dev)
Note on Lustre in backend: Used only at build-time to generate HTML using type-safe elements. Not used at runtime.
Why Wisp + Mist? Wisp is a lightweight Gleam web framework. Mist is the HTTP adapter. Together they provide routing and middleware without heavy dependencies.
Common Tasks
Add a New Survey Question
-
Edit
questions.txtin the repository root:q6_new_id New prompt text Help text / instructions likert_5 --- -
Rebuild:
.\scripts\build_frontend.ps1 -
Restart backend or refresh browser (Backend serves pre-generated HTML, so changes require rebuild)
Modify Survey Styling
The CSS is embedded in backend/src/build.gleam in the style_css() function.
To change styles:
- Edit the CSS inline in
build.gleam - Re-run
gleam run -m buildin backend to regenerate HTML - Refresh browser
Change the Server Port
Edit backend/src/backend_app.gleam (line 21):
|> mist.port(8000) // Change to 8001, 9000, etc.
Then rebuild and run.
Store Form Submissions Persistently
Currently submissions are logged to stdout. To add persistence:
- Add a database dependency (e.g.,
pgsqlor SQLite driver) - Modify
handlers.post_survey()to insert theSubmissionrecord into a database instead ofio.println() - Add a migration or schema setup
The Submission type and validation are already in place; you just need to store them.
Run Locally
# Build static HTML from questions.txt
.\scripts\build_frontend.ps1
# Start backend
cd backend
gleam run
# Prints: Dev shutdown token: ... Quit URL: http://127.0.0.1:8000/__quit?token=...
Then open http://localhost:8000/survey in browser.
Submissions are logged to the terminal where the backend is running.
Troubleshooting
"Failed to read questions.txt" when running build
The build module looks for questions.txt relative to the backend working directory.
Fix:
- Ensure
questions.txtexists in the repository root - Run
gleam run -m buildfrom the backend directory, or - Use the build script which handles this:
.\scripts\build_frontend.ps1
"Survey HTML not found" error (500) when accessing /survey
The backend couldn't find backend/priv/static/survey.html.
Fix: Run the build script:
.\scripts\build_frontend.ps1
Form submission shows "Bad request" (400)
The server couldn't decode the form. Check:
- Required fields (
student_name,student_email,student_grade) are filled in - Browser console for any client-side form errors
- Backend logs in the terminal for the specific decode error
Port 8000 already in use
Change the port in backend/src/backend_app.gleam (line 21):
|> mist.port(8000) // Change to 8001, 9000, etc.
Then rebuild and run.
Last Updated: 2026-04-11
Project Status:
- Backend-only application (frontend folder removed)
- Static HTML generation at build-time using Lustre elements
- No dynamic rendering at runtime
- No persistence (logs to stdout only)
- Dev shutdown token enabled