Auth via the Client SDK key (
osk_ prefix). Async kickoff + polling — POST starts a round and returns identifiers immediately; client polls the detail endpoint for status.Model
- chat_id = one conversation. Issued on the first round; reuse it for follow-ups.
- round_id = one round. Each POST = one round, holding its own prompt + result snapshot.
- Follow-up context (since 2026-05-19): every prior COMPLETED round on the same chat is automatically replayed to llm-demo as a
(user prompt, assistant meal JSON)pair viaconversation_history. Clients just need to send{chat_id, prompt}— short refinement utterances (“add wine”, “no pork this time”) are correctly anchored against the prior round’s ingredients. Rounds whose meal cannot be reconstructed are silently skipped. - Response language: resolved from
profile.localefirst, then the tenant’s default language, thenko. Message-style text (recipe_title/intro_message/recipe_description/recommendation_message/notes…) is emitted in that language. Supported:ko/en/ja. reasonfield is an exception: it surfaces in the tenant admin UI and must switch display language without a refetch when the operator toggles the UI locale, so it always arrives as a{ko, en, ja}object with all three locales populated.- Follow-up cap: more than 8 refinements within the same round →
409 REFINEMENT_CAP_EXCEEDED. Reset by starting a new round (POST) or a new chat. - In-flight round guard (since 2026-05-20): a new POST returns
409 PRIOR_ROUND_IN_FLIGHTonly while the prior round’s step2 hasn’t completed yet. Oncepipeline_progress.step2 == "done"(todos populated), follow-ups are accepted immediately even with step3/step4 still running in the background. Rounds are serialized up to the step2 boundary; step3/4 run independently per round.
1. Kickoff a recommendation
Request
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | string | no | Omit to start a new chat; pass an existing one to append a follow-up round |
prompt | string | yes | User’s natural-language query |
🧑 persona is auto-injected from the profile — no separate body field. The server readsdata.personafromGET /v1/profile/{profile_id}and, if non-empty, prependspersona(페르소나: ...)into the user_query prelude. Update it at profile time viaPUT /v1/profile/{profile_id}with apersonafield (free-form string, nullable).
⚡ fire-and-forget — this endpoint returns immediately (~100ms). The LLM call runs in the background; poll for results. LLM errors like RefinementCapExceeded are surfaced asround.status=error+last_error(not as synchronous 4xx).
Response 202
GET /v1/chat/{profile_id}/recommend/{chat_id} until the last round’s pipeline_progress.step4 == "done".
2. List chats (paged)
| Query | Default | Description |
|---|---|---|
page | 0 | 0-indexed |
limit | 100 | page size (max 500) |
order | desc | sort by created_at, desc / asc |
Response 200
chat_id + total_rounds + a mini rounds[] of {round_no, round_id} only + created_at. Round bodies (prompt / results) come from the detail or single-round endpoints. Pagination metadata is wrapped in pageInfo.
3. Chat detail (rounds[])
KICKED_OFF) rounds against llm-demo and persists the snapshot once the pipeline lands. Already-completed rounds are returned as-is.
Response 200 — in flight (last round still pending)
🔑 Schema consistency (since 2026-05-09) — the snapshot keys are always present even whenpipeline_resultis still empty. Clients should branch on values (pipeline_progress.stepN == "done",items.length > 0) rather than on key presence.
pipeline_progress.stepN == "done"):
step1(~5s): no dedicated keys filled (LLM response complete + meal plan generated)step2(~10s):todos.recipe_title / intro_message / recipe_description / recommendation_message / ingredients[] / items[].productpopulatedstep3(~15s):recommended_items[]populated (reason= category tag,reason_detail= analytical text,reason_detail_marketing= customer copy)step4(~20s):notifications.ad.items(signage) +notifications.coupon[](in-app coupons) populatednotifications.coupon_spot[]is step-independent — the store’s coupon-spot master is fetched from store-API on every poll (not an LLM recommendation)
Response 200 — completed (todos / recommended_items populated)
| Round field | Description |
|---|---|
round_no | 1-indexed position — which question this round answered. The last item’s round_no equals the total round count. |
status | in_progress / completed / error |
back_list_status | llm-demo’s not_started / in_progress / completed (null until first snapshot) |
todos | Shopping list with recipe metadata. Each item is {quantity, product}; total = Σ(product.price × quantity) |
todos.recommendation_message | step1 (meal) personalized rationale that reflects profile signals (name / gender / age / extra) — a human-friendly comment, distinct from recipe_description (generic dish blurb). Emitted in the profile locale |
todos.items | Ordered by in-store pickup route (since 2026-05-20) — stable sort on product.section_code. Order: produce(PR) → deli(DL) → bakery/sweets(BK/SW/SN) → alcohol(AL) → dairy/drinks(DY/DR) → pantry(PA) → meat(MT) → seafood(FI) → frozen(FR). So a picker can walk the categories in a single sweep. Codes outside the table (EC/HH/CK, …) go to the tail. Original order is preserved within a category |
recommended_items | Meal-matching cross-sells. reason = category tag ("cross-sell" / "upsell" / "impulse"), reason_detail = admin/dashboard analytical text (lift / CVR / co_count), reason_detail_marketing = customer-facing copy, product = store-merged catalog row. reason_detail / reason_detail_marketing are always {ko, en, ja} objects — while upstream emits a single locale, the same value is mirrored across all three keys (shape-only consistency) |
notifications.ad | Signage ads bundle { signage, items }. signage = single signage’s {x, y, radius} (or null). items = type=ads entries (top 3). 1-indexed rank by priority_score desc |
notifications.coupon | In-app coupon entries. Each carries location {x, y, radius} resolved from the item’s item_id → store product → Section coords (null when no match) |
notifications.coupon_spot | Store-side coupon-spot master (not an LLM recommendation; fetched from store-API on every poll). Same 19-field schema as coupon[] so iOS can decode both with the same logic. type = "coupon"; recommendation signals (rec_id, reason_*, applied_score, priority_score, method) are all null. discount_pct ← admin discount_value; rank ← spot_no ASC index. location is the spot’s own {x, y, radius} rather than product.location |
notifications.*[].item_id | Store catalog product id. May be null when llm-demo can’t attach it — entry still ships with item_name / item_name_en |
notifications.*[].product | Full store catalog row — id / name / brand / barcode / price / price_unit / src_url (image) / location / section_code / section_name / quantity (null when no match) |
notifications.*[].price / price_unit | Convenience flattening of product.price / product.price_unit for card price hot-path (both null when no match). The legacy price_yen field is dropped — store DB is the single source of truth |
notifications.*[].type | "ads" (signage) / "coupon" (in-app). Same as the group key but kept on each entry too |
notifications.*[].rec_id | Recommendation event id — pass back as items[].rec_id on /trigger/signage or rec_id on /trigger/coupon |
notifications.*[].reason_type / reason_detail / reason_detail_marketing | Step4 reason triple — reason_type = category tag ("cross-sell" / "zone_efficiency" etc.), reason_detail = admin/dashboard analytical text, reason_detail_marketing = customer copy rendered on signage / coupon. reason_detail / reason_detail_marketing are {ko, en, ja} objects (mirrored across keys while upstream emits a single locale) |
last_error | Set on status: error only (e.g. meal_suggest failed: upstream timeout / upstream chat session expired) |
4. Single round
detail.rounds[i] (no chat metadata / profile_summary wrapper):
NOT_FOUND 404.
5. Send signage ads trigger — POST /trigger/signage
Called by the iOS sdk on UWB enter / exit of a signage location-radius. The action field branches behavior (defaults to "enter").
- enter: forwards the round’s
notifications.ad.itemsto the signage → RECOGNIZED screen. Server (1) persists one sdk_chat_event per item (2) fans out to tenant admin SSE (3) forwards to store-API. - exit: signage returns to IDLE immediately. No persistence / SSE, store-API forward only.
Per-signage rendering policy
The gateway forwards the fullitems[] to store-API; which item to show and how is decided by the signage client (per signage_no).
| Signage | IDLE screen | ACTIVE (RECOGNIZED) data source |
|---|---|---|
| #1 (entrance signage) | 1~4 admin-curated slides (cross-fade) | Sorts items by discount_pct desc and shows the top 1 as a single product card. Auto-orients landscape/portrait. reason_detail becomes the marketing copy |
| #2 (in-store signage) | 9-slot admin grid (3 slides × 3 products) | Three sections — ① forgotten[0] (missed item) ② items[1] || items[0] (ad, index-based) ③ one random enabled=true row from store-API signage_event |
forgotten → that area is omitted). Signage #1 is a single-card design so discount_pct ordering matters; #2 displays the ad next to “missed / event” so it uses index 1 first (index 0 is reserved for the stronger signal elsewhere).
Request — Enter (area entry)
The minimal payload is justchat_id + round_id. With action omitted, "enter" is the default. Gateway resolves the round’s persisted notifications.ad.items and fires ADS_TRIGGERED for each.
Request — Exit (area exit)
{ "success": true, "data": { "event_ids": [], "accepted": true, "action": "exit" } }
Request — Enter (items override + forgotten)
You can also passitems explicitly to override (live data, partial set, etc.). Use forgotten to attach items the user left behind in-store (since 2026-05-20):
| Field | Required | Description |
|---|---|---|
chat_id | ✅ | The chat the recommendation came from |
round_id | ✅ | The round |
items | optional | Omit / empty → Gateway looks up the round’s persisted ad items. Provided values take precedence |
items[].rec_id | ✅ (when items sent) | Per-item recommendation event id |
items[].item_id / item_name / item_name_en / brand / zone_id / zone_name / price_yen / discount_pct / applied_score / priority_score / method / reason_type / reason_detail / reason_detail_marketing | optional | Echoed to FIREHOSE + signage. Rendering policy depends on signage_no — see the “Per-signage rendering policy” table above (#1 = discount_pct top 1 single card, #2 = items[1] || items[0] ad + forgotten + event in 3 sections) |
forgotten | optional | Items the user left behind in-store. iOS sdk attaches the relevant todos.items entries verbatim. Each element’s schema is free-form (any) — Gateway passes the array through to the persisted payload, the admin SSE fanout, and the store-API forward at the root level without any transformation. Empty / omitted → key absent. Ignored on exit |
Response 202
event_ids length matches persisted items. Frequency = location entries (one round + one signage = 1 call).
For each item, the server lifts step4 store_reasoning.analyst_comment from the raw pipeline and attaches it as analyst_comment on the SSE payload — surfaced as the rationale on the admin user-analytics signage event.
6. Send coupon issued/redeemed — POST /trigger/coupon
Called by the iOS sdk per notifications.coupon[] entry when issued / redeemed / dismissed inside the app. Signage-agnostic — only (1) persistence and (2) tenant admin SSE fanout.
Request
The minimal payload ischat_id + round_id + rec_id. Gateway matches rec_id against the round’s persisted notifications.coupon[] and fills the rest of the metadata automatically.
| Field | Required | Description |
|---|---|---|
chat_id / round_id | ✅ | Same as above |
rec_id | ✅ | Recommendation event id (match key against the round snapshot) |
kind | optional | "issued" / "redeemed" / "dismissed" (default issued) |
| Others | optional | Auto-filled from the matched round entry when omitted; provided values take precedence |
Response 202
7. Personal recommendation impression log — POST /trigger/recommend
Called by the iOS sdk as a one-shot log when an item from recommended_items[] (step3) is shown or tapped inside the app. Signage / store-API agnostic — only (1) sdk_chat_event persistence (2) tenant admin SSE fanout.
Request
| Field | Required | Description |
|---|---|---|
chat_id / round_id | ✅ | Same as above |
rec_id | ✅ | Recommendation event id (recommended_items[].rec_id or id) |
rec_id against the round’s recommended_items[] and auto-fills item_name / price / reason_detail / reason_detail_marketing into the payload. The step3 surface_reasoning.analyst_comment is also extracted from the raw pipeline and attached as analyst_comment — surfaced as the rationale on the admin user-analytics personal-recommendation event.
Response 202
8. Round barcode bundle — GET /recommend/{chat_id}/{round_id}/barcodes
Returns the round’s products grouped by category alongside EAN-13 PNG (base64). Admin / demo-only — no store-API re-fetch (uses the round’s flattened data + on-the-fly ZXing PNG).
Response 200
- When
barcodeisn’t 13 digits or fails the checksum,barcode_imageisnull— clients only need a null guard. - The same product can appear in multiple categories.
Polling guidelines
- Recommended interval: ~2 s (full pipeline averages ~46 s)
- Inspect only the last
rounds[]entry — earlier rounds are alwayscompleted(orerror) - After a follow-up POST, the array length grows by 1; the new last item starts at
in_progress - On
error/ upstream TTL expiry: start a fresh chat (POST withoutchat_id)
Error codes
| HTTP | code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | Body validation failed (missing prompt, etc.) |
| 404 | NOT_FOUND | profile / chat missing or ownership mismatch |
| 402 | INSUFFICIENT_CREDIT | Tenant credit balance too low |
| 409 | REFINEMENT_CAP_EXCEEDED | More than 8 follow-ups within one round — reset by starting a new round / new chat |
| 409 | PRIOR_ROUND_IN_FLIGHT | Prior round’s step2 hasn’t completed yet — wait for pipeline_progress.step2 == "done". step3/4 still running is fine |
| 500 | INTERNAL_ERROR | Upstream failure |