Skip to content

AI Advisor API & Streaming

All routes are under /api/advisor and are part of AppType, so the frontend calls the configuration endpoints through the typed hc<AppType> client (see Hono Backend & RPC). The streaming endpoint is consumed with fetch and a ReadableStream. Exactly six advisor endpoints are mounted and no wildcard or agent-server route exists (advisorMounting.spec.ts).

Method Path Purpose
POST /api/advisor/ask Non-streaming: runs to completion, returns one answer.
POST /api/advisor/stream SSE streaming run.
GET /api/advisor/config Stored chain and per-provider credential state.
PUT /api/advisor/config/model-chain Replace the model chain.
GET /api/advisor/config/execution-profiles Stored profiles, or defaults.
PUT /api/advisor/config/execution-profiles Replace the profiles.

Common failure: an invalid request body returns HTTP 400 from zValidator before any run starts.


💬 POST /api/advisor/ask and POST /api/advisor/stream

Section titled “💬 POST /api/advisor/ask and POST /api/advisor/stream”

Request body (Content-Type: application/json):

{
"message": "What should I review first in my tax report?",
"threadId": "0f8a2b3c-5d6e-4f70-8a91-b2c3d4e5f607"
}
Field Type Rules
message string 1 to 4000 characters.
threadId string, optional UUID. Omit to start a conversation; send the threadId from a terminal frame to continue it.

/ask always returns 200 with a body discriminated on outcome, including for refused and failed runs:

{
"outcome": "completed",
"text": "Start with the two critical data-quality flags ...",
"receipt": {
"runId": "7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11",
"threadId": "0f8a2b3c-5d6e-4f70-8a91-b2c3d4e5f607",
"startedAt": "2026-05-01T09:30:00.000Z",
"finishedAt": "2026-05-01T09:30:07.412Z",
"providerId": "ollama",
"modelId": "qwen3:8b",
"toolsCalled": ["fiscal_integrity"],
"usage": { "inputTokens": 1420, "outputTokens": 260 },
"executionProfile": "local",
"stepsUsed": 3,
"maxSteps": 15
}
}
{ "outcome": "refused", "reason": "This request asks how to evade ...", "processorId": "grounding-directive-detector" }
{ "outcome": "failed", "code": "NO_MODEL_AVAILABLE" }

The /ask answer has no disclaimer, figuresIncomplete, or cause field; those belong to the streaming contract. /stream returns 200 with Content-Type: text/event-stream; run-level problems are frames, not HTTP errors.


Returns 200:

{
"chain": [
{ "providerId": "ollama", "modelId": "qwen3:8b", "contextWindow": 8192 },
{ "providerId": "anthropic", "modelId": "claude-sonnet-4-5" }
],
"providers": [
{ "id": "openai", "category": { "kind": "ai-model" }, "credential": { "kind": "absent" } },
{ "id": "anthropic", "category": { "kind": "ai-model" }, "credential": { "kind": "locked" } },
{ "id": "ollama", "category": { "kind": "ai-model" }, "credential": { "kind": "present" } }
]
}

chain is [] when unset or unparseable. providers always lists all six ids. credential.kind is present (stored and vault unlocked, or ollama), absent (nothing stored), or locked (stored, vault locked).

Body: an array of chain entries, at least one (see Model Chain and Fallback). Returns 200 { "success": true }, or 400 for an empty array or an entry that violates the shape rules (for example a local ollama entry without contextWindow).

Terminal window
curl -X PUT http://localhost:3001/api/advisor/config/model-chain \
-H 'content-type: application/json' \
-d '[{"providerId":"ollama","modelId":"qwen3:8b","contextWindow":8192}]'

⚖️ GET and PUT /api/advisor/config/execution-profiles

Section titled “⚖️ GET and PUT /api/advisor/config/execution-profiles”

GET returns 200 with the stored profiles or defaultExecutionProfiles(). PUT validates against the ceilings and returns 200 with the stored profiles, or 400 on a violation. The metered block requires a toolBudgets entry for every tool; local has no budgets. A stored row written before a tool existed still loads: on read, its toolBudgets are merged over the defaults, so missing tools take their default budget. PUT stays strict and requires all 25 keys.

{
"metered": {
"maxSteps": 5, "lastMessages": 20, "topNHoldings": 15, "lotsPageSize": 20, "rowsPageSize": 25,
"toolBudgets": {
"portfolio_summary": 4000, "fiscal_integrity": 6000, "token_history": 6000, "asset_allocation": 3000,
"risk_metrics": 1500, "drawdown_curve": 4000, "performance_history": 4000, "kpis": 3000,
"volatility_heatmap": 4000, "spanish_tax_report": 5000, "live_prices": 2000,
"fiscal_integrity_rows": 4000, "token_lots": 4000, "holding_detail": 3000, "account_holdings": 5000,
"tax_year_comparison": 4000, "data_gaps": 4000, "scenario_position_value": 1500, "breakeven_price": 1500,
"explain_metric": 2000, "derivatives_pnl": 4000, "custody_locations": 4000,
"scenario_portfolio_shock": 4000, "concentration_risk": 2000, "tx_search": 5000
}
},
"local": { "maxSteps": 15, "lastMessages": 50, "topNHoldings": 50, "lotsPageSize": 100, "rowsPageSize": 100 }
}

[!WARNING] Unlike the chain (a bad value degrades to NO_MODEL_AVAILABLE), a stored ai_advisor_execution_profiles value that fails to parse is not caught: GET answers HTTP 500 and a run ends failed with INTERNAL_ERROR. Recover by writing a valid value with PUT or deleting the setting.


Each SSE message uses the frame’s kind as the SSE event name and a JSON data payload. A : keep-alive comment is written every 15 seconds. Every frame carries runId. A run ends with exactly one of done, refused, or failed.

kind Fields besides runId
token text
tool-start callId, tool
tool-result callId, tool
tool-error callId, tool, code (INVALID_TOOL_INPUT or USE_CASE_FAILED)
done threadId, providerId, modelId, usage (inputTokens, outputTokens), toolsCalled, executionProfile (local, metered, mixed), stepsUsed, maxSteps, disclaimer, figuresIncomplete
refused threadId, reason, processorId
failed code, optional threadId; cause (kind, providerId, modelId) required on ALL_PROVIDERS_FAILED and forbidden on every other code

Both failed members are .strict(), so a cause on a causeless code is rejected rather than stripped. threadId on failed is optional only because an unexpected error on a brand-new conversation can occur before a thread id exists.

There is deliberately no transport-lost frame: a stream that closes with no terminal frame is synthesised by the client, so a dropped connection can never be confused with a guardrail refusal.

event: tool-start
data: {"kind":"tool-start","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","callId":"call_01","tool":"portfolio_summary"}
event: tool-result
data: {"kind":"tool-result","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","callId":"call_01","tool":"portfolio_summary"}
event: token
data: {"kind":"token","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","text":"Your largest position is "}
event: token
data: {"kind":"token","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","text":"BTC at `15000` USD."}
event: done
data: {"kind":"done","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","threadId":"0f8a2b3c-5d6e-4f70-8a91-b2c3d4e5f607","providerId":"ollama","modelId":"qwen3:8b","usage":{"inputTokens":1834,"outputTokens":212},"toolsCalled":["portfolio_summary"],"executionProfile":"local","stepsUsed":3,"maxSteps":15,"disclaimer":false,"figuresIncomplete":false}
event: refused
data: {"kind":"refused","runId":"9a3b...","threadId":"0f8a2b3c-5d6e-4f70-8a91-b2c3d4e5f607","reason":"This request asks how to evade or hide taxable gains, which this advisor never assists with.","processorId":"grounding-directive-detector"}
event: failed
data: {"kind":"failed","runId":"4d2c...","threadId":"0f8a2b3c-5d6e-4f70-8a91-b2c3d4e5f607","code":"ALL_PROVIDERS_FAILED","cause":{"kind":"auth-rejected","providerId":"anthropic","modelId":"claude-sonnet-4-5"}}
event: failed
data: {"kind":"failed","runId":"b17e...","threadId":"3e5a...","code":"NO_MODEL_AVAILABLE"}

[!NOTE] If the route itself throws, it emits {"kind":"failed","runId":"unknown","code":"INTERNAL_ERROR"} (no threadId) before closing.