Ask a coaching agent one question (SSE)
const url = 'http://localhost:8000/api/agents/ask/';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"agent":"rehab","subject":{"kind":"client","id":"3fa85f64-5717-4562-b3fc-2c963f66afa6"},"question":"Where is the asymmetry coming from?"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url http://localhost:8000/api/agents/ask/ \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "agent": "rehab", "subject": { "kind": "client", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "question": "Where is the asymmetry coming from?" }'Server-sent events stream answering ONE question about one subject, in the caller’s stored conversation about it.
agent is a per-TURN choice. Continuing a thread_id with a different agent is allowed and expected — the next answer comes from the new persona in the SAME conversation, the way a chat client switches model — and the turn records which persona produced it. subject must still match the thread’s: a conversation IS about one athlete or squad, and a mismatch is 404.
The request carries a single question, not a history: the server persists the thread (GET /api/agents/thread/) and replays it into the prompt. That bounds the prompt and means a caller cannot forge assistant turns to steer the answer. Both turns are recorded — the question before the stream starts, so it survives a mid-stream disconnect.
subject.kind is team (the caller’s active team; subject.id is ignored) or client (an athlete on that team; subject.id required). The subject’s history is assembled server-side from the same aggregates the coach sees on the dashboard and client page — the request body never carries analysis data.
Frame sequence: event: meta ({mode, lang, agent, subject, thread} — agent is THIS turn’s persona) → event: tool / event: tool_done ({name, detail?, report?, share?, failed?} — which lookup ran and what it offered; tool additionally carries chart_kind when the lookup is a chart, so a client can reserve its space before the data lands), event: visual (ONE server-built chart payload — {kind, title, subtitle, unit, decimals?, labels, series, band?, reference?, max?}, where kind is one of trend|bars|radar and every value comes from a team-scoped query, never from the model; see DESIGN_AGENTS §D9), and event: token ({text} chunks, concatenate for the answer) → event: citations ({sources: [...]} — curated coaching-corpus section titles, empty when grounding is off) → event: done ({ok, thread}). A provider failure mid-answer yields event: warn and the answer ends with a short interruption note; with no model configured the stream returns a deterministic, data-grounded summary instead.
Answering the agent’s question (#2370). A turn may end on a card frame of kind question: the agent asked, with options the server filled from the platform’s registries. The coach’s answer is the next request, carrying answer: {question_id, option_ids?, values?, skipped?} beside a question that holds their own words for it (what their message shows) and the thread_id of the conversation. Only the newest question in the thread can be answered or changed; every id is re-resolved against the caller’s team. A rejected answer is one event: error frame with HTTP 400 and a detail of no_question, question_not_latest, wrong_pick, unknown_option, unknown_slot or invalid_values (with fields naming the rejected inputs).
An invalid body yields a single event: error frame with HTTP 400. Rate-limited per user (this route is backed by a paid model); exceeding it returns 429. 403 means the team’s plan does not include agents; 404 means the surface is disabled for this deployment, or the subject is not on the caller’s team.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Query Parameters
Section titled “Query Parameters”Reply language (en / ru / es). Defaults to en.
Request Bodyrequired
Section titled “Request Bodyrequired”Body of POST /api/agents/ask/ — ONE question plus what it is about.
The conversation history is deliberately NOT in the body: the server stores
the thread and replays it (agents.threads). That bounds the prompt, and it
means a caller can’t forge “assistant” turns to steer the next answer.
object
What the conversation is scoped to: the caller’s whole team, or one
athlete on it. id is the Client id and is required only for
kind="client" — a team subject is always the caller’s active team, never
an id from the body (that would be a tenancy hole).
object
team- teamclient- client
object
object
object
Examples
Ask the rehab agent about one athlete
{ "agent": "rehab", "subject": { "kind": "client", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "question": "Where is the asymmetry coming from?"}Ask the performance agent about the squad
{ "agent": "performance", "subject": { "kind": "team" }, "question": "What should the squad focus on next?"}Answer the agent's question
{ "agent": "performance", "subject": { "kind": "team" }, "thread_id": "7d9f3c2e-1b4a-4c5d-8e6f-0a1b2c3d4e5f", "question": "Anna Kim", "answer": { "question_id": "0e6b1f9a-2c3d-4e5f-8a7b-6c5d4e3f2a1b", "option_ids": [ "3fa85f64-5717-4562-b3fc-2c963f66afa6" ] }}Body of POST /api/agents/ask/ — ONE question plus what it is about.
The conversation history is deliberately NOT in the body: the server stores
the thread and replays it (agents.threads). That bounds the prompt, and it
means a caller can’t forge “assistant” turns to steer the next answer.
object
What the conversation is scoped to: the caller’s whole team, or one
athlete on it. id is the Client id and is required only for
kind="client" — a team subject is always the caller’s active team, never
an id from the body (that would be a tenancy hole).
object
team- teamclient- client
object
object
object
Body of POST /api/agents/ask/ — ONE question plus what it is about.
The conversation history is deliberately NOT in the body: the server stores
the thread and replays it (agents.threads). That bounds the prompt, and it
means a caller can’t forge “assistant” turns to steer the next answer.
object
What the conversation is scoped to: the caller’s whole team, or one
athlete on it. id is the Client id and is required only for
kind="client" — a team subject is always the caller’s active team, never
an id from the body (that would be a tenancy hole).
object
team- teamclient- client
object
object
object
Responses
Section titled “Responses”text/event-stream of agent-answer frames (see description).
object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}The caller’s tier does not include the coaching agents.
object
One of eight canonical strings. The front-end paywall switches copy + CTA on this value (exact string equality — do not localise). quota_exhausted / seat_limit_reached / client_limit_reached / feature_not_in_plan lead to an upgrade CTA; subscription_suspended leads to a Billing Portal CTA so the buyer can update their payment method; exercise_not_in_plan leads to a contact-us CTA — the movement list is sales-managed, no tier adds to it (#2030). trial_expired / access_ended mean the team is read-only (#2901): everything opens, nothing new is created; they lead to a subscribe CTA and always carry reset_at: null.
quota_exhausted- Session quota for the period reached 0subscription_suspended- Stripe dunning in flight (past_due / unpaid)seat_limit_reached- Adding another team member would exceed plan.max_seatsclient_limit_reached- Adding another Client would exceed plan.max_client_profilesfeature_not_in_plan- The tier does not include this whole feature surfaceexercise_not_in_plan- The (activity, exercise) is not in the team’s exercise grantstrial_expired- The team is read-only: its self-serve trial endedaccess_ended- The team is read-only: its paid plan or granted access ended
ISO-8601 UTC timestamp the team’s current period ends. Null when the team has no live subscription (legacy un-migrated row). On Free + paid this is the renewal moment; quota refreshes to exactly plan.session_quota (NOT additive — leftover sessions don’t roll over).
The active plan’s role string (free, starter, professional, organization, strategic). Same value as current_role — kept as two separate fields for forward-compatibility with a future named-plan split where current_plan could carry an SKU and current_role the tier label.
The active plan’s role string. See current_plan.
Which feature surface is locked. Present ONLY on feature_not_in_plan, so one modal can name what’s gated without a detail string per feature (#773). The only value today is agents (aikynetix/agents/services.py::AGENTS_FEATURE).
Examples
Free team opens an agent conversation
{ "detail": "feature_not_in_plan", "reset_at": null, "current_plan": "free", "current_role": "free", "feature": "agents"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}object
429 — rate limit exceeded. Honour the Retry-After response header (seconds to wait) before retrying; it is sent on every 429.
Example generated
{ "detail": "example"}