Home Playground Docs hub

Tutorial T-01 · Multi-service

Build a support assistant

Verified against local · 2026-09-25 · auth-service, rag-service, evergreen-ai-service, notifications-service (workspace HEAD)
Scenario. Acme Homeware's three-person support team answers the same
questions all day: "How long do I have to return this?", *"When will my
order ship?"*. You will build them an assistant that answers from the
company's FAQ — not from a model's imagination — and pings the team on a
live notification stream when a customer sounds like they need a human.

This tutorial composes four guides instead of repeating them: it assumes you have worked through (or can consult) G-01 Authentication, G-11 Grounding documents, G-10 First AI chat session, and the SSE pattern in G-50 Transforming media. Where those guides explain a contract in depth, this tutorial links instead of re-teaching.

Time: ~45 min · Level: intermediate

Architecture

text
 customer question
        │
        ▼
┌───────────────────────┐   1. JWT (QA login)    ┌────────────────┐
│  support_assistant.sh  │ ─────────────────────▶ │  Auth :40004    │
│  (this tutorial)       │                        └────────────────┘
└───┬───────────┬───────┘
    │ 2. ingest │ 3. grounded chat (use_rag)
    ▼           ▼
┌─────────┐  ┌──────────────────┐
│ RAG      │◀─│ Evergreen AI      │  4. escalation keyword detected
│ :40100   │  │ :40071            │ ──────────────┐
└─────────┘  └──────────────────┘               ▼
                                    ┌──────────────────────┐   5. SSE
                                    │ Notifications         │ ──▶ support
                                    │ publish :40014        │     dashboard
                                    │ subscribe :40013      │
                                    └──────────────────────┘

The data plane is two hops: the FAQ lives in a RAG collection, and an Evergreen AI session is created grounded on that collection — every chat message is answered against the FAQ. The alert plane is a topic on the Notifications service: the script publishes support-escalations events on the loopback port, and any dashboard holding an SSE subscription on the public port receives them.

Prerequisites

(:40100), evergreen-ai-service (:40071), notifications-service (:40013 public / :40014 loopback).

The snippets use playground_a43b973c77e94b7f8b0e64d96c86edf8.

model pulled (the local stack expects Ollama on :11434; the model maps to the pulled qwen3-vl:4b tag). The first grounded chat call pays the model-load cost — subsequent calls are fast.

Companion code

text
InayaLabs-website/docs-src/tutorials/_code/support-assistant/
├── faq.txt                 ← the corpus you will ingest
├── support_assistant.sh   ← the assembled driver (all checkpoints)
└── README.md              ← how to run it standalone

The checkpoint blocks below are the script's stages, one at a time. Download links: faq.txt · support_assistant.sh · README.md

Checkpoint 1 — Authenticate as the support app

The app needs a user JWT (the QA shortcut stands in for the production G-01 flows). One JWT is enough — it is the support app's identity; the escalation notification is published on the app's behalf:

bash
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"acme-support-01\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"acme-support-bot\"}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "support-app JWT obtained (${#TOKEN} chars)"
test "${#TOKEN}" -gt 100 && echo "checkpoint 1 passed: authenticated"

You should see — checkpoint 1 passed: authenticated.

Checkpoint 2 — Ingest the FAQ into a RAG collection

Create a dedicated collection (support_faq — never use hyphens in collection ids, see G-11) and ingest faq.txt. Then prove the corpus is searchable before wiring it to chat:

bash
COLLECTION="support_faq"
AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")
FAQ="InayaLabs-website/docs-src/tutorials/_code/support-assistant/faq.txt"

DOC_ID=$(curl -sf -X POST "${RAG_URL}/v3/ingest?collection_id=${COLLECTION}" \
  "${AUTH[@]}" -H "X-File-Name: faq.txt" -H "Content-Type: application/octet-stream" \
  --data-binary @"${FAQ}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["document_id"])')
echo "ingested FAQ: ${DOC_ID}"

HIT=$(curl -sf -X POST "${RAG_URL}/v3/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION}\", \"question\": \"How long do I have to return an item for a full refund?\"}")
echo "${HIT}" | python3 -c '
import json, sys
d = json.load(sys.stdin)
top = d["chunks"][0]
assert "30 days" in top["content"] or "thirty days" in top["content"].lower(), top["content"][:200]
print("checkpoint 2 passed: top hit", round(top["score"], 3), "mentions the 30-day window")
'

You should see — ingested FAQ: <id>, then checkpoint 2 passed: top hit 0.xxx mentions the 30-day window. If the top hit is off-topic, the ingestion failed — re-run the block (ingest is upsert, not duplicate).

PITFALL

The RAG ingestor keys on the declared file name and rejects unknown extensions — X-File-Name: faq.md fails with 400 unsupported format 'faq.md'. Supported: .pdf, .docx, .pptx, .xlsx, .txt. Markdown is not a supported ingest format; the companion corpus is therefore faq.txt.

Checkpoint 3 — Answer a customer question, grounded

Create an Evergreen session grounded on the collection (use_rag), then ask the exact question customers ask a hundred times a day. The session pins the small Qwen3_VL_4B_Q4 model so the tutorial is reproducible; production would use capability criteria (G-10):

bash
SESSION=$(curl -s -X POST "${EVG_URL}/v3/sessions" \
  "${AUTH[@]}" -H "Content-Type: application/json" \
  -d '{
    "required_modalities": ["Text"],
    "selection_criteria": [ { "Model": { "optionality": "MustHave", "name": "Qwen3_VL_4B_Q4" } } ],
    "system_prompt": "You are the Acme Homeware support assistant. Answer only from the provided FAQ context, in one short sentence.",
    "temperature": 0.2,
    "use_rag": { "collection_id": "'"${COLLECTION}"'", "include_glossary": true }
  }')
echo "${SESSION}"
SESSION_ID=$(echo "${SESSION}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["ai_session_id"])')
test -n "${SESSION_ID}" || { echo "session creation failed (model not pulled? see G-10 404 pitfall)"; exit 1; }

REPLY=$(curl -s -X POST "${EVG_URL}/v3/sessions/${SESSION_ID}/chat-messages" \
  "${AUTH[@]}" -F "user_message=How long do I have to return an item for a full refund?")
echo "${REPLY}" | python3 -c '
import json, sys
msg = json.load(sys.stdin)
text = " ".join(c["Text"] for c in msg["content"] if "Text" in c).lower()
assert "30 day" in text or "thirty day" in text, text
print("checkpoint 3 passed: grounded answer ->", text)
'

You should see — the session id, then something like checkpoint 3 passed: grounded answer -> full refunds are available within 30 days of purchase. The first call can take a minute or more while the backend loads the model; the reply is not streamed here (see G-10 Step 3 for SSE streaming).

Checkpoint 4 — Escalate to a human over Notifications

Now the customer says the quiet part out loud. The script's escalation rule is a keyword match on the customer message — escalate or manager — and the alert plane is a Notifications topic:

  1. Subscribe first (public port :40013), writing the SSE stream to a

file — this stands in for the support dashboard.

  1. Publish a support-escalations event on the loopback port :40014

(the publish route deliberately lives off the public router).

  1. Assert the dashboard receives it.
bash
SSE_FILE=$(mktemp /tmp/t01-escalation.XXXXXX)
curl -s -N -X POST "${NOTIF_URL}/v3/notifications/subscribe/multi" \
  "${AUTH[@]}" -H "Content-Type: application/json" \
  -d "{\"tenant_id\": \"${TENANT_ID}\", \"subscriptions\": [{\"topic\": \"support-escalations\"}]}" \
  > "${SSE_FILE}" 2>&1 &
SSE_PID=$!
sleep 3
echo "dashboard subscribed (pid ${SSE_PID})"

QUESTION="I have asked twice already — escalate this to a manager."
case "$(echo "${QUESTION}" | tr '[:upper:]' '[:lower:]')" in
  *escalate*|*manager*) NEEDS_HUMAN=true ;;
  *) NEEDS_HUMAN=false ;;
esac
test "${NEEDS_HUMAN}" = "true" || { echo "escalation keyword not detected"; exit 1; }

PUB=$(curl -s -X POST "${NOTIF_PRIV_URL}/v3/notifications/publish" \
  "${AUTH[@]}" -H "Content-Type: application/json" \
  -d "{\"tenant_id\": \"${TENANT_ID}\", \"topic\": \"support-escalations\", \"sub_topic\": \"ticket-4711\", \"payload\": \"{\\\"question\\\": \\\"${QUESTION}\\\", \\\"priority\\\": \\\"high\\\"}\"}")
echo "${PUB}" | python3 -c 'import json,sys; n=json.load(sys.stdin)[0]; print("published:", n["topic"], n.get("sub_topic"))'

sleep 3
grep -q "support-escalations" "${SSE_FILE}" \
  && echo "checkpoint 4 passed: dashboard received the escalation" \
  || { echo "no escalation received"; kill "${SSE_PID}" 2>/dev/null; exit 1; }
kill "${SSE_PID}" 2>/dev/null

You should see — dashboard subscribed, published: support-escalations ticket-4711, then checkpoint 4 passed: dashboard received the escalation. The SSE event carries topic, sub_topic, and the JSON payload — the dashboard would render it as a ticket card.

NOTE

Publish lands on the loopback port (:40014) while dashboards subscribe on the public port (:40013) — both routers share one in-process broker, so a publish reaches every live subscription of the tenant. Keeping publish off the public router means untrusted callers can receive notifications but can never inject them.

Checkpoint 5 — Failure injection: kill RAG, watch the assistant degrade, recover

A support assistant that silently hallucinates when its knowledge base is down is worse than no assistant. Prove the failure is visible:

bash
pkill -x rag-service && echo "rag-service killed"

DOWN_CODE=$(curl -s -o /tmp/t01-down.txt -w "%{http_code}" -m 5 \
  -X POST "${RAG_URL}/v3/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION}\", \"question\": \"refund window?\"}" || echo "000")
echo "search while RAG down: ${DOWN_CODE}"
test "${DOWN_CODE}" = "000" -o "${DOWN_CODE}" -ge 500 \
  && echo "failure is visible (no silent wrong answers)"

source /tmp/inaya_env.sh
export INAYA_LABS_ENVIRONMENT=local
nohup /Users/test/projects/Software/InayaLabs/target/debug/rag-service > /tmp/rag-service.log 2>&1 &
for i in $(seq 1 20); do
  CODE=$(curl -s -m 2 -o /dev/null -w "%{http_code}" "${RAG_URL}/v3/health" 2>/dev/null || echo 000)
  echo "rag health poll ${i}: ${CODE}"
  [ "${CODE}" = "200" ] && break
  sleep 2
done
test "${CODE}" = "200" || { echo "rag-service did not come back"; exit 1; }

UP_HIT=$(curl -sf -X POST "${RAG_URL}/v3/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION}\", \"question\": \"How long do I have to return an item for a full refund?\"}")
echo "${UP_HIT}" | python3 -c '
import json, sys
d = json.load(sys.stdin)
assert d["chunks"], "no chunks after recovery"
print("checkpoint 5 passed: knowledge base recovered, corpus intact (restart does not wipe the store)")
'

You should see — search while RAG down: 000 (connection refused), failure is visible, health polls climbing to 200, then checkpoint 5 passed: knowledge base recovered, corpus intact — the FAQ survives the restart because the RAG store is on disk, not in memory.

TIP

In production you would not restart by hand: the platform's deployment layer re-schedules the replica, and your app retries with backoff (see G-03 Errors, retries & idempotency) — the sequence above is exactly what a retry loop experiences.

Ship it

passkeys or federated sign-in — and point the base URLs at the deployed hosts (*-sandbox.inayalabs.com today; your own domain in production). The key pair becomes a real tenant credential issued by the tenant admin.

criteria (context length, parameter count — G-10's TIP) so the platform picks the best pulled model per request.

Evergreen AI can classify intent (route the same chat through a second session with a classifier system prompt) before publishing.

an agent can discover and drive the same flow (G-02); the platform's llms.txt indexes the contracts for agent discovery platforms.

Cleanup

bash
# end the chat session (G-10 Step 5), then remove the corpus:
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "${EVG_URL}/v3/sessions/${SESSION_ID}" "${AUTH[@]}"
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "${RAG_URL}/v3/documents/${DOC_ID}" "${AUTH[@]}"

Expected — both 204s (see G-11 for the RAG delete semantics: 200 for an unknown id, so treat this as best-effort cleanup, not a check).

Next journeys