π A tiny web app that generates a season-aware ramen recipe (style, broth, tare, noodles, toppings, garnish, steps, shopping list).
π§  Built with Ruby (Sinatra + Puma), JSON-schema validation, and the OpenAI Agents API.
πΎ Repo: https://github.com/swallace100/ramen-chef-agent
Intro
Many AI recipe demos just post unstructured text. I wanted something that was testable and that follows a set format. This "ramen chefβ agent app knows that itβs October in Tokyo, suggests seasonal ingredients, and returns a clean JSON plan that the UI can render without guesswork.
This app serves a single page (vanilla JS) and one API endpoint. It builds a small season context, asks the model to respond as strict JSON, validates that JSON against a schema, and renders a tidy plan in Japanese or English
Tech Stack
- Language: Ruby 3.4+
- Framework: Sinatra + Puma
- Libraries: ruby-openai, oj, json_schemer, dotenv, rack-cors
- Tools: Rake tasks (setup, dev, run, lint, test)
- Data: data/japanese_seasonal.yml (month β suggested ingredients)
Everything runs locally.
Architecture Overview
.
ββ app.rb                   # Sinatra app: HTML UI + API routes
ββ services/
β  ββ ramen_agent.rb        # Agent: schema, prompt, OpenAI call, 
ββ public/
β  ββ app.js                # Fetch + render (safe, resilient)
ββ data/
β  ββ japanese_seasonal.yml # Month β seasonal ingredients
ββ puma.rb                  # Puma config
ββ Rakefile                 # setup/dev/run tasks
ββ resources/
β  ββ.env.sample            # copy to .env with OPENAI_API_KEY
ββ config.ru                # rack entry
Flow:
Browser β Sinatra (/api/recommend) β RamenAgent β OpenAI (JSON) β JSON Schema validation β Rendered plan
All requests are validated. If the model drifts, the app returns a graceful fallback instead of breaking the UI.
JSON-First Planning
The agent enforces a schema so the UI never has to guess field names.
# services/ramen_agent.rb (excerpt)
Schema = {
  "type" => "object",
  "properties" => {
    "season_context" => {
      "type" => "object",
      "properties" => {
        "date" => { "type" => "string" },
        "month" => { "type" => "string" },
        "location" => { "type" => "string" },
        "suggested_ingredients" => { "type" => "array", "items" => { "type" => "string" } }
      },
      "required" => %w[date month location suggested_ingredients],
      "additionalProperties" => false
    },
    "style" => { "type" => "string" },
    "broth" => { "type" => "string" },
    "tare"  => { "type" => "string" },
    "noodles" => { "type" => "string" },
    "toppings" => { "type" => "array", "items" => { "type" => "string" } },
    "garnish"  => { "type" => "array", "items" => { "type" => "string" } },
    "method_steps" => { "type" => "array", "items" => { "type" => "string" } },
    "shopping_list" => { "type" => "array", "items" => { "type" => "string" } },
    "serving_note" => { "type" => "string" }
  },
  "required" => %w[
    season_context style broth tare noodles toppings garnish method_steps shopping_list serving_note
  ],
  "additionalProperties" => false
}
Validation code:
schemer = JSONSchemer.schema(Schema)
plan = Oj.load(model_json, mode: :strict)
raise "invalid" unless schemer.valid?(plan)
Frontend
A minimal page with a Generate Plan button. The JS shows a loading state, escapes strings (XSS-safe), renders lists, and displays a βRaw JSONβ toggle for debugging.
// public/app.js (excerpt)
const $ = (id) => document.getElementById(id);
document.addEventListener("DOMContentLoaded", () => {
  $("go")?.addEventListener("click", async () => {
    const box = $("result");
    box.style.display = "block";
    box.textContent = "Generatingβ¦";
    const payload = {
      location: $("location")?.value || "",
      language: $("language")?.value || "ja-JP",
      notes: $("notes")?.value || ""
    };
    const resp = await fetch("/api/recommend", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload)
    });
    const data = await resp.json();
    // renderPlan(box, data) β outputs Style/Broth/Tare/Noodles/etc.
  });
});
Environment Setup
- Clone + env
git clone https://github.com/yourname/seasonal-ramen-chef
cd seasonal-ramen-chef
cp .env.sample .env     # add your OPENAI_API_KEY=...
- Install (project-local gems)
bundle exec rake setup
- Run (auto-reload)
bundle exec rake dev
# open http://localhost:4567
(Prefer plain run? bundle exec rake run)
Example Request
curl -s -X POST http://localhost:4567/api/recommend \
  -H "Content-Type: application/json" \
  -d '{"location":"Osaka","language":"en-US","notes":"no onions please"}'
Returns a JSON plan with style, broth, tare, noodles, toppings, garnish, method_steps, shopping_list, and serving_note, plus a season_context like { date, month, location, suggested_ingredients }.
Lessons Learned
- Including a schema makes it so users get output with the same style every time and a missed field won't break the UI.
- Month + locale gives the agent a good baseline for ingredient suggestions without the need for more prompting.
- ja-JP vs en-US makes it useful in Japan and abroad.
Repository + License
π Full source: https://github.com/swallace100/ramen-chef-agent
βοΈ License: MIT
Possible future features
- A mode to get a recipe based on ingredients the user has on hand.
- Diet/allergen filters (vegetarian, pork-free, nut-free).
- Weekly planner export (PDF/Markdown).
- Budget/servings sliders and rough price hints per bowl.
- More food types beyond ramen.
If you fork this and add a new feature, drop a link and share!
 


 
    
Top comments (0)