AI Advisor API & Streaming
📡 AI Advisor API & Streaming
Section titled “📡 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.
⚙️ GET /api/advisor/config
Section titled “⚙️ GET /api/advisor/config”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).
🔗 PUT /api/advisor/config/model-chain
Section titled “🔗 PUT /api/advisor/config/model-chain”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).
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 storedai_advisor_execution_profilesvalue that fails to parse is not caught:GETanswers HTTP 500 and a run endsfailedwithINTERNAL_ERROR. Recover by writing a valid value withPUTor deleting the setting.
🌊 Stream Frame Contract (SSE)
Section titled “🌊 Stream Frame Contract (SSE)”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.
Example: a Grounded Answer
Section titled “Example: a Grounded Answer”event: tool-startdata: {"kind":"tool-start","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","callId":"call_01","tool":"portfolio_summary"}
event: tool-resultdata: {"kind":"tool-result","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","callId":"call_01","tool":"portfolio_summary"}
event: tokendata: {"kind":"token","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","text":"Your largest position is "}
event: tokendata: {"kind":"token","runId":"7c1e0a52-3f4b-4f08-9d8e-2b6a1c9d0e11","text":"BTC at `15000` USD."}
event: donedata: {"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}Example: a Guardrail Refusal
Section titled “Example: a Guardrail Refusal”event: refuseddata: {"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"}Example: Every Provider Failed
Section titled “Example: Every Provider Failed”event: faileddata: {"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"}}Example: No Model Configured
Section titled “Example: No Model Configured”event: faileddata: {"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"}(nothreadId) before closing.