Home Playground Docs hub

Guide G-40 · Communication

Publishing and reading a feed

Verified against local · 2026-09-25 · feed-command-service/feed-query-service/feed-workflow-service 0.1.0 (workspace HEAD, auth + moderation-fixes)
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

:40167/:40168 + workflow running, or the shared dev subdomains). The workflow is what publishes posts — without it, writes succeed but reads stay empty.

small local model automatically; on macOS it resolves via Ollama).

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).

calls translate directly).

Concepts (only what this journey needs)

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.

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.

POST /v3/feed/search query the published view; pending or quarantined entries are invisible to readers.

mediaFiles, parentId, and — on sentiment/interaction — entryId, sentimentType, interactionType. Sending snake_case fails deserialization (422 missing field).

the new id as a quoted JSON string ("60OKQCQ3…"), not an object — parse it accordingly.

content and/or a replacement userHashtags list (the list you send replaces the previous one). Another user's entry → 403; unknown entry → 404.

plaintext.

Step 1 — Publish a post

One POST creates the entry; the quoted-string id comes back immediately:

bash
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:

bash
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:

bash
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:

bash
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):

bash
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:

bash
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):

bash
#!/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

Agent prompt — copy and hand to your agent
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