Skip to content

How to call the API for a research query

Run a RAG research query end-to-end: authenticate, discover the workflow, and stream a grounded answer. The examples assume a local dev stack (make run-dev, DISABLE_AUTH=true) on http://localhost:8000; against a deployed environment, swap the host and use a real token.

Every request must carry the tenant id. For REST, that is the X-Organization-Id header; for the WebSocket, it is the organization_id query parameter (browsers cannot set custom headers on a WebSocket handshake).

BASE=http://localhost:8000
ORG_ID=$(grep '^ORGANIZATION_ID=' backend/.env.local | tail -1 | cut -d= -f2-)
TOKEN=dev      # any string works under DISABLE_AUTH=true; use a real JWT otherwise

1. Authenticate

Under the dev stack, auth is bypassed and any bearer token is accepted. Against a deployed environment you need a real token:

  • Azure AD (Entra ID) — obtain a JWT via the MSAL flow. The altaforge-ra-cli tool does this for you (altaforge-ra-cli --profile prod auth ms get-token); see the CLI setup runbook.
  • IP allowlist — if the deployment uses the ip_allowlist strategy and your client IP is registered, exchange it for a short-lived token (no credentials needed):
curl -X POST "$BASE/auth/ip-token" -H "X-Organization-Id: $ORG_ID"
# {"access_token":"<jwt>","token_type":"bearer","expires_in":120}

POST /auth/ip-token is unauthenticated (it is how a caller without a token gets one). It returns 401 if your IP is not in the requested org's allowlist, and 503 if IP_ALLOWLIST_JWT_SECRET is not configured. Tokens are short-lived (default 120 s).

2. Confirm the tenant's configuration

curl "$BASE/config" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Authorization: Bearer $TOKEN"

GET /config returns the tenant's frontend feature flags (escalation, quick links, manual escalation, IP-allowlist admin).

3. Pick a document category

A research query is scoped to a category (and optionally a set of subcategories). List the categories available to the tenant:

curl "$BASE/documents/categories" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Authorization: Bearer $TOKEN"

Note a category_id from the response — you will pass it as category_id in the workflow input.

4. (Optional) Ask which workflow should handle the query

If A/B workflow routing is enabled for the tenant (ENABLE_WORKFLOW_ROUTING), ask the backend which workflow to use before opening the socket:

curl "$BASE/workflow/route?query=How%20do%20I%20apply%3F" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Authorization: Bearer $TOKEN"
# {"workflow":"RAG"}   # or {"workflow":"Agentic"}

When routing is disabled (the default) this always returns RAG. If you skip this step, use the default workflow id langgraph-rag-chat.

5. Run the query over the WebSocket

Research queries run as a LogicStream workflow over a WebSocket at /workflow, so results stream back as they are produced. The token and org id go in the query string:

ws://localhost:8000/workflow?token=<jwt>&organization_id=<org-uuid>

workflow_id is optional and defaults to langgraph-rag-chat.

After the connection opens, send exactly one StartWorkflow frame. Its input object is a WorkflowInput (workflows/rag_chat_models.py):

{
  "variant": "start_workflow",
  "config": null,
  "input": {
    "query": "What are the eligibility requirements?",
    "chat_id": "11111111-1111-1111-1111-111111111111",
    "user_id": "ignored-server-overrides-this",
    "category_id": 1,
    "subcategory_ids": null,
    "message_id": "22222222-2222-2222-2222-222222222222",
    "search_mode": "azure_ai_search"
  }
}

Key WorkflowInput fields:

Field Required Notes
query yes (in practice) The user's question.
chat_id yes Conversation id; maintains multi-turn state.
user_id yes Overridden by the server with the authenticated identity.
category_id yes Document category to search within.
subcategory_ids no List of subcategory ids to filter by. null means all subcategories; a null entry in the list includes uncategorized documents.
search_mode no azure_ai_search (default) or agentic. When omitted, resolves to the org's DEFAULT_SEARCH_MODE.
message_id no Client-generated UUID linking the routing log to the stored message.
include_retrieved_context no Include retrieved chunks in the output (for evaluation). Default false.
history no Prior conversation as a JSON array of messages.

The server ignores the client-supplied user_id, organization_id, and is_virtual_user, replacing them with values derived from the authenticated connection.

The server then streams WorkflowEvent frames (JSON) back over the socket until the workflow completes and the connection closes. The final answer is Markdown with inline citations [1], [2], and a reference_footnotes list mapping each citation to a document name, page number, and chunk id (see AgentResponse / WorkflowOutput in workflows/rag_chat_models.py).

A minimal Python client using websockets:

import asyncio, json, websockets

async def ask():
    org = "36b2b7ae-cd2f-51ab-b9a5-369ca259991d"  # your ORGANIZATION_ID
    token = "dev"                                   # a real JWT against a deployed env
    uri = f"ws://localhost:8000/workflow?token={token}&organization_id={org}"
    async with websockets.connect(uri) as ws:
        await ws.send(json.dumps({
            "variant": "start_workflow",
            "config": None,
            "input": {
                "query": "What are the eligibility requirements?",
                "chat_id": "11111111-1111-1111-1111-111111111111",
                "user_id": "placeholder",
                "category_id": 1,
            },
        }))
        async for frame in ws:
            print(frame)   # each frame is a serialized WorkflowEvent

asyncio.run(ask())

TODO(SME): document the exact WorkflowEvent variants a client should expect (e.g. token deltas, the final structured answer, UserInputRequest) and the recommended way to detect the terminal frame. The event schema is defined in the vendored logicstream package (logicstream.server.WorkflowEvent / logicstream.events), not in this repo.

6. Read the conversation back (non-virtual users)

Chat and message history are persisted for non-virtual users. Fetch them via REST:

# List chat sessions for the authenticated user
curl "$BASE/chats/" -H "X-Organization-Id: $ORG_ID" -H "Authorization: Bearer $TOKEN"

# Messages for one chat
curl "$BASE/chats/11111111-1111-1111-1111-111111111111/messages" \
  -H "X-Organization-Id: $ORG_ID" -H "Authorization: Bearer $TOKEN"

These endpoints are guarded by require_non_virtual_user: a caller holding the App.User.Virtual role receives 403, because virtual users have session-scoped history only.

Errors you may hit

Status Meaning
400 Missing or malformed X-Organization-Id / organization_id.
401 Missing, expired, or invalid token (or the tenant has no resolvable auth config).
403 Role not permitted (e.g. non-admin on /admin, virtual user on history endpoints).
503 Auth config lookup hit a DB outage, or /auth/ip-token when the JWT secret is unset.