Skip to content

Ask a coaching agent one question (SSE)

POST
/api/agents/ask/
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.

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.

lang
string

Reply language (en / ru / es). Defaults to en.

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
agent
required
string
>= 1 characters <= 32 characters
subject
required

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
kind
required
  • team - team
  • client - client
string
Allowed values: team client
id
string format: uuid
nullable
question
required
string
>= 1 characters <= 4000 characters
thread_id
string format: uuid
nullable
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?"
}

text/event-stream of agent-answer frames (see description).

Media typeapplication/json
object
detail
required

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

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

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

string
Examplegenerated
{
"detail": "example"
}

The caller’s tier does not include the coaching agents.

Media typeapplication/json
object
detail
required

One of five 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.

  • quota_exhausted - Session quota for the period reached 0
  • subscription_suspended - Stripe dunning in flight (past_due / unpaid)
  • seat_limit_reached - Adding another team member would exceed plan.max_seats
  • client_limit_reached - Adding another Client would exceed plan.max_client_profiles
  • feature_not_in_plan - The tier does not include this whole feature surface
string
Allowed values: quota_exhausted subscription_suspended seat_limit_reached client_limit_reached feature_not_in_plan
reset_at
required

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

string format: date-time
nullable
current_plan
required

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.

string
current_role
required

The active plan’s role string. See current_plan.

string
feature

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

string
Examples
Example402—AgentsNotInPlan

Free team opens an agent conversation

{
"detail": "feature_not_in_plan",
"reset_at": null,
"current_plan": "free",
"current_role": "free",
"feature": "agents"
}
Media typeapplication/json
object
detail
required

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

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

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

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

429 — rate limit exceeded. Honour the Retry-After response header (seconds to wait) before retrying; it is sent on every 429.

string
Examplegenerated
{
"detail": "example"
}