DEV Community

Steven Wallace
Steven Wallace

Posted on

Building a Season-Smart Ramen Chef Agent with Ruby + OpenAI

🍜 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

App UI

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Validation code:

schemer = JSONSchemer.schema(Schema)
plan = Oj.load(model_json, mode: :strict)
raise "invalid" unless schemer.valid?(plan)
Enter fullscreen mode Exit fullscreen mode

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.
  });
});
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode
  • Install (project-local gems)
bundle exec rake setup
Enter fullscreen mode Exit fullscreen mode
  • Run (auto-reload)
bundle exec rake dev
# open http://localhost:4567
Enter fullscreen mode Exit fullscreen mode

(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"}'
Enter fullscreen mode Exit fullscreen mode

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)