MAF — API Reference
Three surfaces:
- REST endpoints — the dashboard
/api/*. - WebSocket channels — realtime event feed.
- Redis streams — the data + control plane.
Each endpoint name links to its handler in
src/maf/dashboard/routers/ —
the dashboard is split across one router module per concern. A handful
of routes (data sources sampler, app config) still live in
src/maf/dashboard/api.py.
REST endpoints
Arenas
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/arenas |
list_arenas |
All arenas with schedule + source count |
GET /api/arenas/{name} |
get_arena |
Full arena config |
GET /api/arenas/{name}/config |
get_arena_config |
Raw arena config (with ETag header for optimistic locking) |
PUT /api/arenas/{name}/config |
update_arena_config |
Save edited YAML — pydantic-validated (422), If-Match-guarded (412), atomic write |
GET /api/arenas/{name}/last_decision |
get_arena_last_decision |
Latest trail entry summary |
GET /api/arenas/{name}/trail |
list_trail |
Recent trail entries |
GET /api/arenas/{name}/trail/{id} |
get_trail_entry |
Full trail entry |
POST /api/arenas/{name}/run |
run_arena |
Dispatch a synchronous arena run |
POST /api/arenas/{name}/backtest |
run_backtest |
Backtest a trading arena over a date range |
GET /api/arenas/{name}/freshness |
arena_freshness |
Per-binding freshness for the Setup tab badges |
GET /api/arenas/{name}/config example
Response headers include ETag: <16-hex> — pass it back on PUT as
If-Match: <value> to get optimistic-locking. PUT without If-Match
skips the concurrency check.
PUT /api/arenas/{name}/config outcomes
| Status | Meaning |
|---|---|
| 200 | Saved. Response body includes the new ETag. |
| 404 | Arena YAML file not found. |
| 412 | If-Match header doesn't match current ETag — someone else saved while you were editing. |
| 422 | Pydantic validation failed. Body's detail field has the field path + reason. File is untouched. |
| 500 | Atomic write failed. Tempfile is cleaned up; file is untouched. |
POST /api/arenas/{name}/run example
Request:
Show raw JSON
{
"target": {"ticker": "NVDA"}
}Response (truncated):
Show raw JSON
{
"arena": "market_pulse",
"trail_id": "20260516_094822_312",
"signal": "BUY",
"agent_signals": [{"agent": "price_analyst", "signal": "BULLISH", "confidence": 0.72, "...": "..."}],
"synthesis": {"score": 0.32, "verdict": "BUY", "confidence": 0.61,
"reasoning": "..."},
"source_metrics": [],
"reports": {"price_analyst": "...", "news_analyst": "..."},
"decisions": {"synthesis_agent": "BUY"},
"trace": {},
"target": {"ticker": "NVDA"}
}GET /api/arenas/{name}/freshness example
Show raw JSON
{
"arena": "market_pulse",
"now_ms": 1778948000000,
"bindings": [
{
"name": "bars_1m_target",
"adapter": "trtools2_bars",
"source_type": "stream",
"stream": "trtools2:bars:1m",
"length": 80,
"age_seconds": 5.4,
"status": "live"
},
{
"name": "kronos_forecast_1m",
"adapter": "kronos_forecast",
"source_type": "key",
"length": 1,
"age_seconds": 12.0,
"status": "live",
"emit_stream": "kronos:forecasts:emitted"
},
{
"name": "history_bars",
"adapter": "trtools2_api",
"source_type": "external",
"detail": "HTTP API — trtools2 dashboard (default :8888)",
"status": "external"
}
]
}Status values: live (age < 5 min), stale (5 min – 1 h), old
(≥ 1 h), empty (stream exists but no entries), missing
(cache/stream absent), external (HTTP/SQL, no caching),
request_response (round-trips through Redis), unknown.
Triggers
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/triggers/library |
triggers_library |
Prebuilt trigger rules loaded from config/trigger_templates.yaml. Hot-reloads on mtime change. |
POST /api/triggers/validate |
triggers_validate |
Run a when: expression through safe_eval against an optional sample payload. Returns {ok, result, error}. |
POST /api/triggers/validate example
Request:
Show raw JSON
{
"expr": "abs(payload.prob_up_delta) > 0.15 or payload.direction_flipped",
"sample_payload": {"prob_up_delta": 0.22, "direction_flipped": false}
}Response (success):
Show raw JSON
{"ok": true, "result": true, "error": null}Response (parse error):
Show raw JSON
{"ok": false, "result": null, "error": "safe_eval: syntax: invalid syntax"}Channels
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/channels |
api_channels |
Categorised list of every Redis Stream |
GET /api/channels/preview?stream=…&count=25 |
api_channel_preview |
Decoded entries + inferred schema |
GET /api/channels example
Show raw JSON
{
"channels": [
{
"stream": "kronos:forecasts:emitted",
"category": "derived",
"label": "Kronos — forecasts emitted",
"description": "Compact emit per (symbol, timeframe)…",
"source": "catalog",
"length": 12,
"last_id": "1778923456789-0"
}
],
"categories": ["arena_output", "derived", "input", "control"]
}GET /api/channels/preview example
Show raw JSON
{
"stream": "kronos:forecasts:emitted",
"count": 5,
"entries": [
{
"stream_id": "1778923456789-0",
"ts_ms": 1778923456789,
"fields": {
"symbol": "NVDA",
"direction": "NEUTRAL",
"prob_up": 0.27,
"prob_up_delta": -0.23,
"direction_flipped": false,
"model": "NeoQuasar/Kronos-small"
}
}
],
"schema": [
{"name": "symbol", "types": {"string": 5}, "presence": 5, "samples": ["NVDA"], "is_nested": false}
]
}Watch list
| Method · Path | Handler | Behaviour |
|---|---|---|
GET /api/watch?kind=symbol |
list_watch |
Current watch entries |
POST /api/watch |
add_watch |
Add an entry (extends TTL on repeat) |
DELETE /api/watch/{target_id}?kind=symbol |
remove_watch |
Remove every entry matching id (and optionally kind) |
POST /api/watch example
Request:
Show raw JSON
{
"target_id": "NVDA",
"kind": "symbol",
"ttl_seconds": 21600,
"attrs": {"horizon": "1m"}
}Response:
Show raw JSON
{"ok": true, "target_id": "NVDA", "kind": "symbol", "expires_at": 1778945123.4}System
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/system/status[?force=1] |
system_status |
Live connectivity checks: redis, ollama, trtools2, fomo2, mirofish, kronos_refresher, mirofish_refresher. Cached 5 s; ?force=1 bypasses cache. |
GET /api/streams/health |
streams_health |
Length + age per known stream |
GET /api/data/sources |
list_data_sources |
All source bindings across arenas + adapter catalog |
GET /api/data/sources/{arena}/{name}/sample |
data_source_sample |
One-shot adapter fetch — useful for "what shape does this return?" |
GET /api/system/status example
Show raw JSON
{
"cached_at": 1778948696.5,
"all_ok": false,
"checks": [
{"name": "redis", "ok": true, "detail": "redis://localhost:6379/0", "latency_ms": 3.4},
{"name": "ollama", "ok": true, "detail": "39 models", "latency_ms": 240.5},
{"name": "trtools2", "ok": true, "detail": "engine.db.questdb importable", "latency_ms": 0.2},
{"name": "fomo2", "ok": true, "detail": "fomo2 package importable", "latency_ms": 0.2},
{"name": "mirofish", "ok": true, "detail": "http://localhost:5101 200", "latency_ms": 12.1},
{"name": "kronos_refresher", "ok": true, "detail": "alive · 4s ago · 3 watched", "latency_ms": 1.1},
{"name": "mirofish_refresher", "ok": false, "detail": "not running (no heartbeat — start `python -m maf` for service mode)", "latency_ms": 0.9}
]
}LLM
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/llm/rankings?category=programming |
api_llm_rankings |
OpenRouter usage leaderboard joined against Ollama Cloud catalog |
GET /api/llm/picker?profile=synthesis |
api_llm_picker |
What the smart picker would choose for this profile right now |
Modules + sources catalog
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/modules |
list_modules |
All loaded data modules with config + adapter counts |
PUT /api/modules/{name} |
update_module_config |
Edit module config in default.yaml — requires restart |
GET /api/sources |
list_sources |
Every registered source adapter |
POST /api/sources/test |
test_source |
Instantiate an adapter with given params and call fetch() |
Specialised arena outputs
| Method · Path | Handler | Returns |
|---|---|---|
GET /api/oracle/envelopes?count=20 |
list_oracle_envelopes |
Latest crowd-simulation envelopes from maf:arena:crowd_simulation:output |
GET /api/mastermind/decisions?count=20 |
list_mastermind_decisions |
Latest Decision envelopes from maf:arena:mastermind:output |
Wizard
| Method · Path | Handler | Returns |
|---|---|---|
POST /api/wizard |
wizard_create |
Plan + scaffold a new arena from a free-text description |
Docs viewer
| Method · Path | Handler | Returns |
|---|---|---|
GET /docs |
page_docs_index |
This documentation site |
GET /docs/{slug} |
page_doc |
Rendered markdown doc (with src: links rewritten to /source/...) |
GET /source/{path:path}?line=N |
page_source |
Source file viewer with line anchors + Pygments highlight |
GET /docs.css |
docs_css |
Pygments stylesheet for code highlighting |
Pages
All HTML pages render templates from src/maf/dashboard/templates/.
Every <script> block on these pages is run through node --check in
CI (test) so a stray
ternary can't break onclick handlers without failing the build.
| Path | Page |
|---|---|
GET / |
Dashboard — arena cards + run dialog |
GET /live |
Live event feed |
GET /channels |
Channels browser |
GET /data |
Data + streams health |
GET /llm |
LLM picker × rankings |
GET /modules |
Modules config |
GET /sources |
Sources catalog |
GET /wizard |
Arena wizard |
GET /mastermind |
Mastermind decisions feed |
GET /oracle |
Crowd-sim envelopes feed |
GET /arenas/{name} |
Per-arena detail page (Run / Setup / Backtest / Sources / History / Memory / Raw YAML tabs) |
GET /docs |
Docs index |
GET /docs/{name} |
One rendered doc |
GET /more |
308 redirect to / (legacy) |
GET /favicon.ico |
204 No Content (silences browser noise) |
WebSocket channels
Both endpoints push JSON messages — one event per message.
| Path | Filter | Source |
|---|---|---|
/ws/events |
None — all events | ws_events |
/ws/arenas/{name} |
payload.arena == name |
ws_arena |
Message shape
Show raw JSON
{
"kind": "phase.complete",
"arena": "market_pulse",
"arena_id": "abc-123",
"phase": "analysis",
"ts": "2026-05-16T09:48:22.123+00:00",
"correlation_id": "manual-1",
"payload": {
"pattern": "parallel",
"agents": ["price_analyst", "news_analyst", "vol_regime_analyst", "kronos_specialist"],
"elapsed_s": 12.4
},
"stream_id": "1778923456789-0"
}Heartbeats ({"kind": "ws.heartbeat"}) are sent every ~5 s when there
are no new events so clients can detect dead connections.
Redis streams
Input streams (MAF reads from these)
| Stream / key | Producer | Adapter |
|---|---|---|
trtools2:bars:1m |
trtools2 feed engine | Trtools2BarsSource |
trtools2:bars:1h |
trtools2 feed engine | same, different timeframe config |
trtools2:news |
trtools2 news ingester | Trtools2NewsSource |
trtools2:indicators |
trtools2 indicator pipeline | Trtools2IndicatorsSource |
trtools2:strategy:events |
trtools2 strategy engine | Trtools2StrategyEventsSource |
| trtools2 HTTP API (port 8888) | trtools2 dashboard | Trtools2ApiSource — uses TT2_API_KEY |
| Any MCP server (HTTP/SSE) | external | MCPSource — generic; bind once per (url, tool) |
https://mcpv2.eodhd.dev/v1/mcp |
EODHD | EODHDSource — uses EODHD_API_KEY, 77 tools (fundamentals, earnings calendar, news, EOD bars, ...) |
fomo2:enriched |
fomo2 enrichment pipeline | Fomo2StreamSource |
fomo2:reports |
fomo2 LLM goal pipeline | drives MirofishRefresher |
fomo2:requests:in |
(MAF writes) | on-demand fomo2 calls |
fomo2:requests:out |
fomo2 sidecar replies | Fomo2RequestSource |
Derived streams + cache keys (MAF writes these)
| Stream / key | Producer | Consumer |
|---|---|---|
kronos:forecast:{sym}:{tf} (string key) |
KronosRefresher |
KronosForecastSource |
kronos:forecasts:emitted |
KronosRefresher | trigger dispatcher, dashboard |
mirofish:sim:{report_id} (string key) |
MirofishRefresher | MirofishSimSource |
mirofish:sims:emitted |
MirofishRefresher | trigger dispatcher |
maf:actions:out |
trading arenas via ActionOutbox |
ActionConsumer |
maf:decisions:out |
non-trading arenas via DecisionOutbox |
dashboards |
maf:executions:out |
ActionConsumer |
ExecutionHarvester |
maf:outcomes:out |
engine (when wired) | ExecutionHarvester |
maf:arena:{name}:output |
per-arena emit phase | dashboards |
maf:refresher:kronos:heartbeat (string key, TTL'd) |
KronosRefresher._write_heartbeat |
system_status |
maf:refresher:mirofish:heartbeat (string key, TTL'd) |
MirofishRefresher._write_heartbeat |
system_status |
Control streams
| Stream | Purpose | Producer | Consumer |
|---|---|---|---|
maf:control:in |
Inbound commands | ControlClient, dashboard, dispatcher |
ControlInbox |
maf:control:out |
Acks keyed by correlation_id | ControlInbox | ControlClient.send() |
maf:events |
Lifecycle events (best-effort) | EventBus |
dashboard /ws/events, telemetry |
Control commands
The data field of each maf:control:in entry is JSON:
Show raw JSON
{
"command": "run_arena",
"correlation_id": "abc",
"args": {
"arena": "market_pulse",
"target": {"ticker": "NVDA"},
"action_mode": "manual",
"emit_action": true
}
}Supported commands:
| Command | Args | Result |
|---|---|---|
run_arena |
{arena, target, action_mode?, emit_action?} |
{arena_id, signal, synthesis_verdict, …} |
configure_arena |
{arena, selected_analysts?, max_iterations?} |
{changed: {...}} |
set_data_source |
{arena, name, adapter, config} |
{arena, source, adapter} |
reload_config |
{} |
{before, after} |
health |
{} |
{arenas, modules, redis_url, streams} |
list_arenas |
{} |
{arenas: [{name, description, schedule, phases, sources}, …]} |
See ControlInbox.COMMANDS for the
allow-list. Unknown commands return an explicit NACK ack rather than
silent drop.
Envelope conventions
Almost every stream uses the same wire shape: one Redis Stream entry,
one field named data, value is a JSON string. Consumers JSON-decode
the data field and (often) validate against a Pydantic envelope. This
keeps the wire schema-naive — consumers don't have to learn which
Redis fields map to which envelope keys.
The exception: trtools2:bars:1m and friends use multi-field entries
(one field per OHLCV column) because that's the existing trtools2
convention. The adapters handle both.