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
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
- The local stack running:
auth-service(:40004),rag-service
(:40100), evergreen-ai-service (:40071), notifications-service (:40013 public / :40014 loopback).
- The tenant key pair in
INAYA_API_KEY/INAYA_APP_KEYand a tenant id.
The snippets use playground_a43b973c77e94b7f8b0e64d96c86edf8.
jq-free — the snippets parse JSON withpython3only.- Run all snippets from the repository root (
/Users/test/projects/Software/InayaLabs) — the companion code is addressed by relative path. - The AI model backend running:
ollama servewith theQwen3_VL_4B_Q4
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
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 standaloneThe 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:
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:
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):
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:
- Subscribe first (public port
:40013), writing the SSE stream to a
file — this stands in for the support dashboard.
- Publish a
support-escalationsevent on the loopback port:40014
(the publish route deliberately lives off the public router).
- Assert the dashboard receives it.
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/nullYou 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:
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
- Production access: swap the QA login for the real flows (G-01) —
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.
- Model choice: replace the pinned
Qwen3_VL_4B_Q4with capability
criteria (context length, parameter count — G-10's TIP) so the platform picks the best pulled model per request.
- Escalation logic: keyword matching is the tutorial's placeholder;
Evergreen AI can classify intent (route the same chat through a second session with a classifier system prompt) before publishing.
- Agentic operation: every service in this tutorial is MCP-enabled —
an agent can discover and drive the same flow (G-02); the platform's llms.txt indexes the contracts for agent discovery platforms.
Cleanup
# 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
- Build a social app backend (T-02) — the Communication stack: users, profiles, feed, and DMs.
- Choosing Evergreen vs Bella vs Chloe (G-13) — swap the assistant's brain for a managed persona or a council.
- Agentic inventory audit (T-05) — drive this whole flow from an agent instead of a script.