Journey. As a developer, I want my app's users to publish posts, reply to
each other, and read a ranked timeline with search — while the platform
handles content moderation, hashtagging, sentiment, and expiry for me.
The Feed platform is a CQRS trio: Feed Command (FDC) owns every write, Feed Query (FDQ) owns every read, and Feed Workflow (FDW) runs the publish pipeline in the background — a civility check via Evergreen AI, then platform hashtag and sentiment generation, then promotion to the published timeline (posts auto-expire 30 days after publishing). This guide walks publish → read → search → edit → react → delete, verified against the local stack (FDC :40167, FDQ :40168); the shared dev subdomains behave identically.
Prerequisites
- Feed Command, Feed Query and Feed Workflow are reachable (local stack
:40167/:40168 + workflow running, or the shared dev subdomains). The workflow is what publishes posts — without it, writes succeed but reads stay empty.
- Evergreen AI must be able to serve the moderation model (the workflow pins a
small local model automatically; on macOS it resolves via Ollama).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEY, and a user JWT
in TOKEN (obtain one via guide G-01; the full example below fetches two via the QA sign-in shortcut — the feed is a multi-actor journey).
curlpluspython3for JSON handling (any HTTP client works; the same
calls translate directly).
Concepts (only what this journey needs)
- Writes are author-bound from the JWT. Every post/reply/sentiment/
interaction is attributed to the JWT's sub; the tenant comes from the JWT's tenant_id. There is no way to write on behalf of another user over REST.
- Publishing is asynchronous.
POST /v3/feed/postreturns immediately
with the new entry's id, but the entry is pending. The workflow picks it up (within seconds), runs the civility check, generates platform hashtags + sentiment, and promotes it to the published timeline. Unsafe content is quarantined instead. Poll the feed until your entry appears.
- Reads only see published entries. Both
GET /v3/feedand
POST /v3/feed/search query the published view; pending or quarantined entries are invisible to readers.
- All write bodies are camelCase.
languageCode,userHashtags,
mediaFiles, parentId, and — on sentiment/interaction — entryId, sentimentType, interactionType. Sending snake_case fails deserialization (422 missing field).
- Create returns a JSON string.
POST /v3/feed/postand/replyreturn
the new id as a quoted JSON string ("60OKQCQ3…"), not an object — parse it accordingly.
- Edits are author-only and wholesale for hashtags.
PATCHaccepts new
content and/or a replacement userHashtags list (the list you send replaces the previous one). Another user's entry → 403; unknown entry → 404.
- Content is encrypted at rest per-tenant; you always send and receive
plaintext.
Step 1 — Publish a post
One POST creates the entry; the quoted-string id comes back immediately:
ALICE_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-alice\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
-H "Authorization: Bearer ${ALICE_TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d '{"content": "Transplanted the tomato seedlings into the south beds today.", "languageCode": "en-US", "userHashtags": ["gardening", "spring"], "mediaFiles": []}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "post id: ${POST_ID}"Expected response — 200 with a quoted id string (e.g. "60OKQCQ3A9C3EK9PB9936C2N6HBJ8HPJB0OLEI2I8K"). mediaFiles is a list of {"filename", "dataBase64"} objects uploaded to the CDN; leave it empty for text-only posts.
Step 2 — Reply, then wait for the pipeline
Replies reference a parent id. Then give the workflow a moment — it performs three AI passes (civility, hashtags, sentiment) per pending entry:
ALICE_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-alice\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
BOB_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-bob\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
# The post id from Step 1:
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
-H "Authorization: Bearer ${ALICE_TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d '{"content": "Setting up the irrigation timers for the season.", "languageCode": "en-US", "userHashtags": ["irrigation"], "mediaFiles": []}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
REPLY_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/reply" \
-H "Authorization: Bearer ${BOB_TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"parentId\": \"${POST_ID}\", \"content\": \"Which timer model did you pick?\", \"languageCode\": \"en-US\", \"userHashtags\": [], \"mediaFiles\": []}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "reply id: ${REPLY_ID}"Expected response — 200 with another quoted id. The reply validates that the parent exists (unknown parent → 404).
Step 3 — Read the timeline (poll until published)
GET /v3/feed returns the published, ranked timeline for the caller's tenant, paginated with page / page_size. Fresh posts take a few seconds to clear the pipeline, so poll:
BOB_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-bob\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
for attempt in $(seq 1 24); do
COUNT=$(curl -s "${FDQ_URL}/v3/feed?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB_TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
| python3 -c 'import json,sys; print(len(json.load(sys.stdin)["entries"]))')
if [ "$COUNT" -gt 0 ]; then break; fi
sleep 5
done
echo "entries after pipeline: ${COUNT}"
curl -s "${FDQ_URL}/v3/feed?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB_TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
| python3 -c 'import json,sys
r = json.load(sys.stdin)
for e in r["entries"][:3]:
print("%s | %s | %s | sentiment: %s" % (e["id"][:8], e["content"][:45], e["hashtags"], e["sentiment"]))'Expected response — once the pipeline promotes the entries (typically 10–60 s), entries is non-empty and each entry shows id, authorId, content, languageCode, the combined hashtags (yours plus the AI-generated platform tags), a one-word platform sentiment (Positive/Negative/Neutral), mediaUrls, and createdAt — all camelCase.
Step 4 — Search the feed
POST /v3/feed/search filters by any combination of author_id, hashtag, platform_sentiment, and full-text query, with the same pagination params on the query string:
BOB_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-bob\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
AUTH=(-H "Authorization: Bearer ${BOB_TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
echo "-- by hashtag"
curl -s -X POST "${FDQ_URL}/v3/feed/search?page=1&page_size=10" "${AUTH[@]}" \
-H "Content-Type: application/json" -d '{"hashtag": "irrigation"}' \
| python3 -c 'import json,sys; print("hits:", len(json.load(sys.stdin)["entries"]))'
echo "-- full-text"
curl -s -X POST "${FDQ_URL}/v3/feed/search?page=1&page_size=10" "${AUTH[@]}" \
-H "Content-Type: application/json" -d '{"query": "tomato seedlings"}' \
| python3 -c 'import json,sys; print("hits:", len(json.load(sys.stdin)["entries"]))'
echo "-- sentiment filter"
curl -s -X POST "${FDQ_URL}/v3/feed/search?page=1&page_size=10" "${AUTH[@]}" \
-H "Content-Type: application/json" -d '{"platform_sentiment": "Neutral"}' \
| python3 -c 'import json,sys; print("hits:", len(json.load(sys.stdin)["entries"]))'Expected response — each search returns 200 with a {entries: [...]} body; hashtag matches on user or platform tags, query is PostgreSQL full-text (plainto_tsquery), and platform_sentiment matches the workflow's AI-assigned label. A search that matches nothing returns entries: [], not an error.
Step 5 — Edit (author only) and react
PATCH is author-only; other users get 403, unknown entries 404. The sentiment and interaction bodies are camelCase too (entryId, sentimentType, interactionType):
ALICE_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-alice\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
BOB_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-bob\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
AUTH=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
# A fresh post owned by alice:
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
-H "Authorization: Bearer ${ALICE_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "Testing the solar fence charger.", "languageCode": "en-US", "userHashtags": ["fencing"], "mediaFiles": []}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "-- bob (non-author) patches -> HTTP $(curl -s -o /dev/null -w '%{http_code}' \
-X PATCH "${FDC_URL}/v3/feed/post/${POST_ID}" \
-H "Authorization: Bearer ${BOB_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "hacked"}')"
echo "-- alice (author) patches -> $(curl -s -X PATCH "${FDC_URL}/v3/feed/post/${POST_ID}" \
-H "Authorization: Bearer ${ALICE_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "Testing the solar fence charger — holding at 7.2 kV.", "userHashtags": ["fencing", "solar"]}')"
echo "-- unknown entry -> HTTP $(curl -s -o /dev/null -w '%{http_code}' \
-X PATCH "${FDC_URL}/v3/feed/post/no-such-entry" \
-H "Authorization: Bearer ${ALICE_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "x"}')"
REACT_CODE=$(curl -s -o /tmp/g40-react.json -w '%{http_code}' \
-X POST "${FDC_URL}/v3/feed/sentiment" \
-H "Authorization: Bearer ${BOB_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"entryId\": \"${POST_ID}\", \"sentimentType\": \"Like\"}")
echo "-- bob reacts -> HTTP ${REACT_CODE} $(cat /tmp/g40-react.json)"
test "$(cat /tmp/g40-react.json)" = '"Success"' || { echo "sentiment failed"; exit 1; }
VIEW_CODE=$(curl -s -o /tmp/g40-view.json -w '%{http_code}' \
-X POST "${FDC_URL}/v3/feed/interaction" \
-H "Authorization: Bearer ${BOB_TOKEN}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"entryId\": \"${POST_ID}\", \"interactionType\": \"View\"}")
echo "-- bob views -> HTTP ${VIEW_CODE} $(cat /tmp/g40-view.json)"
test "$(cat /tmp/g40-view.json)" = '"Success"' || { echo "interaction failed"; exit 1; }Expected response — non-author patch → 403; author patch → 200 "Success" (content and hashtags updated; the platform tags are untouched); unknown entry → 404; sentiment and interaction both → 200 "Success".
Step 6 — Delete (author only, permanent)
DELETE removes the entry with its content, sentiments, and interaction history. Only the author may delete:
ALICE_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\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g40-alice\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
AUTH=(-H "Authorization: Bearer ${ALICE_TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "Temporary entry to be deleted.", "languageCode": "en-US", "userHashtags": [], "mediaFiles": []}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
curl -s -o /dev/null -w "delete -> HTTP %{http_code}\n" \
-X DELETE "${FDC_URL}/v3/feed/post/${POST_ID}" "${AUTH[@]}"
curl -s -o /dev/null -w "delete again -> HTTP %{http_code}\n" \
-X DELETE "${FDC_URL}/v3/feed/post/${POST_ID}" "${AUTH[@]}"Expected response — delete -> HTTP 204; a second delete returns 404 (the entry is gone, so the author check has nothing to authorize).
Full working example
A complete publish → poll → search → react → delete cycle (feed_publish.sh) — self-contained via the QA shortcut (local/QA environments only; production uses the G-01 flows):
#!/usr/bin/env bash
set -euo pipefail
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g40-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
ALICE=$(login g40-alice); BOB=$(login g40-bob)
# 1. Alice publishes, Bob replies
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
-H "Authorization: Bearer ${ALICE}" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d '{"content": "First cutting of hay is baled and barned.", "languageCode": "en-US", "userHashtags": ["hay-season"], "mediaFiles": []}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "published: ${POST_ID}"
curl -sf -o /dev/null -X POST "${FDC_URL}/v3/feed/reply" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"parentId\": \"${POST_ID}\", \"content\": \"How many bales?\", \"languageCode\": \"en-US\", \"userHashtags\": [], \"mediaFiles\": []}"
echo "replied"
# 2. Poll the timeline until the pipeline publishes
for attempt in $(seq 1 24); do
if curl -sf "${FDQ_URL}/v3/feed?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
| grep -q "hay is baled"; then
echo "visible on the feed"; break
fi
sleep 5
done
# 3. Search and react
curl -sf -X POST "${FDQ_URL}/v3/feed/search?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d '{"hashtag": "hay-season"}' | grep -q "baled" && echo "search found it"
curl -sf -o /dev/null -X POST "${FDC_URL}/v3/feed/sentiment" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"entryId\": \"${POST_ID}\", \"sentimentType\": \"Like\"}" && echo "reacted"
# 4. Alice deletes her post
curl -sf -o /dev/null -X DELETE "${FDC_URL}/v3/feed/post/${POST_ID}" \
-H "Authorization: Bearer ${ALICE}" "${KEYS[@]}" && echo "deleted"(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; FDC_URL / FDQ_URL are the Feed Command / Feed Query base URLs — http://127.0.0.1:40167 / http://127.0.0.1:40168 locally; TENANT_ID is your tenant's id.)
Try it live
Open the Playground → Communication — the Feed console exercises the same publish → timeline → search cycle against the live Feed services.
Copy agent prompt
You are working with the Inaya Labs Feed platform. Feed Command (writes) is at
FDC_URL and Feed Query (reads) at FDQ_URL; the Auth Service is at AUTH_URL;
the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and TENANT_ID. Obtain
two user JWTs via POST /v3/auth/qa/login {"device_id","locale","tenant_id",
"username"} with usernames "g40-agent-a" and "g40-agent-b" if TOKENs are not
already set. Steps: (1) As agent-a, POST FDC /v3/feed/post {"content",
"languageCode": "en-US", "userHashtags": ["agent-demo"], "mediaFiles": []}
(the response is a quoted JSON string — that is the post id) and report it.
(2) As agent-b, POST FDC /v3/feed/reply with that parentId and a short
question. (3) Poll FDQ GET /v3/feed?page=1&page_size=10 as agent-b every 5
seconds until the post appears (the workflow publishes asynchronously), then
report the entry's hashtags and platform sentiment. (4) Search FDQ POST
/v3/feed/search with {"hashtag": "agent-demo"} and report the hit count.
(5) As agent-b, attempt to PATCH FDC /v3/feed/post/{id} and report the 403;
then as agent-a PATCH new content and report the 200. (6) As agent-b, POST
FDC /v3/feed/sentiment {"entryId": "<id>", "sentimentType": "Like"} — note
the camelCase fields — and report the 200. (7) As agent-a, DELETE
/v3/feed/post/{id} and report the 204, then repeat and report the 404. Never
log the JWTs; report status codes per step.API reference
Next journeys
- Direct messaging between users (G-41) — private conversations on the Messaging CQRS pair.
- Managing users & profiles (G-30) — the user records every author maps back to.
- Authentication & sessions (G-01) — the token lifecycle that every call in this guide depends on.