A while ago now, I entered a Bad UX competition, where a prize went to the worst date-picker. Since then I have finally found some time to polish it and make it safe to release to the public; here it is. I didn't win the contest, so I guess my demo wasn't bad enough. Is that a compliment?
The idea was to parody a future where AI assistants are so widespread and overused that they become a hindrance rather than a help to the user. In this case, the date-picker form element is intercepted by a bot who insists on entering the date for you, and must establish your date of birth through tedious questioning; it refuses to simply accept the date when told directly.
Besides showing it off, I thought I'd share some of the challenges I faced and lessons learned. This was my first time building an AI-powered application and my first time using the OpenAI API.
Initially, the conversation worked like this:
sequenceDiagram
participant C as Client
participant S as Server
participant O as OpenAI
C->>S: Start conversation
Note over S: Create session in memory
S->>O: System prompt
O-->>S: "What season were you born in?"
Note over S: Store conversation history
S-->>C: Reply + session ID
C->>S: "Around Thanksgiving" + session ID
Note over S: Retrieve conversation history
S->>O: System prompt + history + answer
O-->>S: "Was it early or late November?"
Note over S: Update conversation history
S-->>C: Reply
When the user focuses the date field, the client makes a request to initialise the conversation.
This creates a chat session in application-server memory and sends an initial request to OpenAI (specifically gpt-4o-mini), along with the prompt defining the rules of the challenge.
The assistant's first response, containing its greeting and opening question, is returned to the client along with the session ID. The client includes that ID with each subsequent answer, allowing the server to retrieve the corresponding conversation history. Each new OpenAI request includes the accumulated message history.
Once the assistant has correctly guessed a date, it responds with a specially formatted message that the client can parse and inject back into the form.
sequenceDiagram
participant C as Client
participant O as OpenAI
Note over C,O: Requests are relayed through the<br />server, as previously shown
O-->>C: "November 12, 1965?"
C->>O: "Correct!"
O-->>C: SUCCESS 1965-11-12
I was on free tier, and didn't want to pay for Cold Start Prevention. This meant that if a user was trying the demo after no-one touched it for a while, they'd have to wait a while for the assistant to "wake up".
Solution: I triggered the serverless function in the background as soon as the app opened, so hopefully the initial greeting and question would be fetched by the time the user opened the conversation dialog, and the chat would flow straight away. The fallback is to have a graphic with a timeout asking the user to be patient if is taking unusually long.
This is the question of balance that comes with any game: making it challenging enough to entertain the user without being so challenging that they give up in frustration.
I also needed it to be consistently "just right", knowing that competition judges might only try the app once and move on, especially if it doesn't hit the Goldilocks zone on the first run.
I found myself having to repeat and rephrase rules such as this multiple times in the prompt:
The user is not allowed, in any circumstance, to mention specific dates, months or years. If the user mentions these, you must ignore this information, and tell the user you cannot use it.
Yet the LLM would still occasionally accept an explicitly stated date.
On the other hand, the assistant might ask uselessly vague follow-up questions in response to the user's very specific clues. For example "My birthday is around Thanksgiving, the year before the Calgary Winter Olympics" might have the assistant guess that your birthday is "in the latter half of the 1980s" and ask whether it was in the earlier or later part of November (Thanksgiving should already narrow it down to the final week).
The final niggle was that the LLM didn't seem to appreciate how human memory works. It always wanted clues by asking about memories from the day the user was born, which of course isn't going to happen.
One practical solution that is commonly advised is to tell LLMs what to do rather than what not to do: "Don't let the user do this" -> "If the user says this, do this".
Otherwise, this just took practice and refinement. Also, since competition judging is over, I allowed myself to stop caring so much.
This turned out to be two problems with one solution.
Firstly, I was keeping every conversation session in server memory and never removing completed ones. That meant the amount of in-memory session state could grow for as long as a function instance remained alive. I hadn't noticed because serverless function instances can be recycled between requests, clearing that in-memory state for me.
This explains the second issue I was having that took a while to figure out. If the function instance was recycled during a conversation, the UI would stay stuck in its pending state. The client still had a session ID, but the new function instance had no corresponding session in memory. In-memory state is convenient, but in a serverless environment it must never be treated as durable.
The single solution was to store the conversation history in client-side application state. Each request could then include the complete transcript, removing the need to create, retrieve or persist conversation sessions on the server.
sequenceDiagram
participant C as Client
participant S as Server
participant O as OpenAI
C->>S: Start conversation
S->>O: System prompt
O-->>S: "What season were you born in?"
S-->>C: Reply
Note over C: Conversation history stored client-side
C->>S: "Around Thanksgiving" + full transcript
S->>O: System prompt + transcript
O-->>S: "Was it early or late November?"
S-->>C: Reply
Move state to the client where appropriate, but keep trusted instructions on the server: the system prompt remains server-side and is prepended before the request is sent to OpenAI.
By sharing the demo with the public, I was concerned about abuse. I'm still on Vercel's free tier, so I had no financial concern there. Similarly, at the expected level of traffic, the OpenAI API is inexpensive enough for the demo to run publicly. What makes me anxious is the thought of people, maybe just out of curiosity rather than malice, hammering requests at a rate that manages to rack up a bill.
The first step was to set up spend limits. I also have "auto-reload" disabled just to make sure (I don't have anything critical running on my account). But this won't stop a single user from burning through all the available credit, blocking everyone else from trying the demo.
To prevent that, I added a Vercel Firewall rate-limit rule. Thankfully the single rule available on the free tier is all I needed to set up an HTTP 429 "Too Many Requests" response whenever someone sends more than 20 messages within 60 seconds to the app's sole API endpoint. This gives more people opportunity to try the demo before my credit runs out.
I also put explicit bounds on anything that can directly influence cost. The client caps the length of the conversation, and each OpenAI request uses max_completion_tokens to bound the size of the model's response. That keeps both conversation growth and per-request output costs from expanding without limit.
Of course it's important to remember that failure handling is part of the UX too; otherwise, a protection intended to make the application more robust can simply make it appear broken to the user.
Calling an AI API is easy; building a good user-facing feature around it still requires ordinary software engineering.
After a long time of using AI coding assistants, I'm glad I had an excuse to experiment with a client-facing AI assistant in a casual setting, and now have the confidence to apply what I learned to real-world applications.
New capabilities also introduce new failure and abuse modes. Application-level protections shouldn't be the only line of defence: infrastructure rate limiting and billing controls can help contain both abuse and unexpected costs. And, as with any distributed application, it still matters where state lives and what assumptions you make about its persistence.
I hope you enjoyed the demo. If you haven't experimented with putting AI directly into a user-facing feature yet, hopefully some of these lessons are useful. Either way, let me know what you think.
You can find more of my projects at hdv.dev.