effgen serve · 1.0.0

A dashboard, a playground and a model browser that fetch nothing

effgen serve already carries its web surfaces: 12 panels of live traffic, cost and traces at /dashboard, and a browser playground at /playground that runs a model, shows the tools it called and hands the run back as curl, cli, python. Both are 8 files inside the package — 184 kB, no CDN, no external font, nothing fetched when the page opens.

12
Dashboard panels
5
Same-origin routes
184 kB
Both surfaces, 8 files
0
Outside hosts reached
The effGen dashboard from top to bottom: five summary cards, SLO burn rates and a latency chart, a per-model table, HTTP status chips, a per-route table, recent agent runs, stored history, the span stream, a run timeline, a two-agent topology graph, the model catalog and the raw Prometheus metrics.
The whole page in the dark theme, scrolled through in place. The panels below are the same page, photographed one at a time.effgen serve --port 8000 · the whole page at 1440px, after the traffic below went through the server
The effGen dashboard from top to bottom: five summary cards, SLO burn rates and a latency chart, a per-model table, HTTP status chips, a per-route table, recent agent runs, stored history, the span stream, a run timeline, a two-agent topology graph, the model catalog and the raw Prometheus metrics.
The whole page in the light theme, scrolled through in place. The panels below are the same page, photographed one at a time.effgen serve --port 8000 · the whole page at 1440px, after the traffic below went through the server

Every figure on this page is a screenshot of a running server, taken with a browser after real traffic went through it: six calls that succeeded, one naming a model that does not exist, one body with no messages field, a probe of a route that is not served, and a two-agent team run. That is why the error columns have numbers in them and the cost column has a in it. The counters that include the dashboard’s own polling keep climbing while a page is open, which is why they differ between one figure and the next. Nothing here is a mock-up.

The dashboard

12 panels, and each one names what it measured

Figures from different sources are never added together. The per-model rows are scoped to a (model, provider) pair, spend that could not be matched to a row is stated below the table rather than spread across it, and the route panel carries the denominator that turns a status code into an error rate.

Is it healthy

The five cards and the two panels beside them: how much went through, how much of it failed, how close the latency and error budgets are to being spent, and how the last few intervals compare.

  • SLO Burn Rates
  • Request Latency (recent)

What did it cost, and which model spent it

One row per model and provider, so a model served by two providers reports each provider's own latency tail and each provider's own spend.

  • By Model

What failed, and where

A status code on its own says a request failed; it does not say which route failed or why. These two panels carry the denominator.

  • HTTP Responses by Status
  • Responses by Route

What ran

The runs this process has seen, the durable history behind them, and the spans each run produced — live, and laid out on a timeline.

  • Recent Agent Runs
  • History
  • Live Span Stream
  • Run Timeline

What the work looks like

A team or a workflow as a graph, and the catalog every model id on the page resolves against.

  • Agent Topology
  • Model Catalog

The numbers underneath

Every registered Prometheus metric and its current value, unsummarised.

  • Prometheus Metrics (raw)
Five cards: total requests 7; model-call errors 1, with HTTP 4 4xx and 0 5xx printed under it; average latency 1.480s; session cost $0.000400 across 6 priced and 1 unpriced run; 998 tokens used.
The summary row. The cost card says how many of the runs behind it were priced and how many were not, because a total that quietly counts an unpriced call as $0 is a total nobody can act on.GET /dashboard/data.json → .metrics
A table with one row per model and provider. gemini-3.1-flash-lite on gemini: 4 calls, 0.0 percent errors, p95 2.400s, outcome ok, 234 tokens in and 50 out, $0.000133. gpt-5-nano on openai: 2 calls, 0.0 percent, p95 4.750s, outcome ok, 54 and 660, $0.000267. gpt-9-does-not-exist on openai: 1 call, 100.0 percent errors, p95 0.098s, outcome not_found once, 0 tokens either way, and a dash where the cost would be.
Per model and provider: calls, error rate, p95, the dominant failure class, tokens and spend. The model that does not exist has a where its cost would be and not_found · 1 where its outcome is. Model, Provider, Calls, Error rate, p95 and Cost all sort on click.GET /dashboard/data.json → .by_model
bash
curl -s http://127.0.0.1:8000/dashboard/data.json | jq '.by_model[0]'
what that returned
{
  "model": "gemini-3.1-flash-lite",
  "provider": "gemini",
  "calls": 4,
  "errors": 0,
  "input_tokens": 234,
  "output_tokens": 50,
  "outcomes": {
    "ok": 4
  },
  "top_error": null,
  "top_error_hint": null,
  "error_rate": 0,
  "p95_latency_s": 2.4,
  "cost_usd": 0.000133
}

The row a failure produces carries its own fix

outcomes tallies the recorded outcome label verbatim, top_error names the most frequent failure, and top_error_hint is the same remediation sentence the command line prints for that class — so the panel says what to do, not only that something went wrong.

Spend that cannot be attributed to any row is reported apart, in unattributed_cost_usd, so the cost column always sums to money actually attributed.

Three chips under the heading HTTP responses by status, each naming the code and its count as text: 200 twenty-one times, 404 three times, 422 once.
One chip per status code, each stating the code, the class and the count as text — so the panel does not depend on colour.GET /dashboard/data.json → .by_status
Requests, failures and error rate per route and method, worst first. POST /v1/chat/completions: 9 requests, 3 errors, 33.3 percent, by class 2xx six and 4xx three. A GET row labelled other: 14 requests, 1 error, 7.1 percent — the note above the table says it collects the dashboard's own polling and any unrouted request. GET /health and GET /v1/models: one request each, 0.0 percent.
The same failures with a denominator. Traffic outside the recorded route list — including the dashboard’s own polling — is labelled other rather than being folded into a route it did not touch.GET /dashboard/data.json → .by_route
Spans grouped by run under the heading Run timeline. Each run is a labelled block with its total duration and span count on the right, and inside it a run bar above a model bar, positioned by start offset and sized by duration; one run also carries a short tool bar. The last run, against gpt-9-does-not-exist, is drawn in the error colour.
Spans grouped by run, positioned by start offset and sized by duration. The failed run is drawn as failed rather than as a very fast one.GET /dashboard/data.json → .recent_spans
The History panel: 9 runs, 0 sessions, with the directory they are stored in named above a text search and a status filter. Each row carries the time, the model, the task as a link that opens a detail pane, the cost, the duration and a status badge. A saved-sessions table underneath reads: no saved sessions.
Stored runs and saved sessions, filterable by text and status. This is the same durable history effgen runs and effgen sessions read, so a run started from a script shows up here.GET /dashboard/history.json
SLO burn rates: p99 latency at 100.0 percent of its budget, error rate at 100.0 percent, availability at 85.7 percent, with p50 1.56s, p95 4.13s, p99 4.83s and a two-second target printed underneath.
How much of the p99 latency, error-rate and availability budgets has been spent, with the measured percentiles and the target underneath.GET /dashboard/data.json → .slo
A filled line chart headed Request latency (recent), its vertical axis labelled 0.00s to 1.70s, with the measured average sitting just under 1.5s across the recorded intervals. It is drawn on a canvas by the page itself.
Average latency over recent polling intervals, drawn on a canvas by the page itself. No chart library is loaded, because none is shipped.GET /dashboard/data.json → .metrics.avg_latency_s

The 5 routes the page reads

RouteTypeDescription
/dashboard/data.jsonThe five cards, the SLO burn, the per-model rows, the status and route breakdowns, the recent runs and the buffered spans. Polled every five seconds.
/dashboard/spansServer-sent events, one JSON object per span, pushed as they are recorded.
/dashboard/catalog.jsonEvery model the catalog knows, for the catalog panel.
/dashboard/history.jsonStored runs and saved sessions, for the history panel.
/dashboard/topology.jsonRecent team and workflow executions as node-link graphs. `?limit=` bounds it.

Every one is same-origin. The page issues no other request.

The playground

Run a model in the browser, then take the run with you

Pick a model or a preset, attach tools, set temperature and a token cap, and run it — streamed or not. The answer arrives with its tokens, its cost and its latency, the tools that ran are listed with what they were given and what they returned, and the whole thing is offered back as curl, cli, python.

The playground after a run. On the left the compose column: an API key field noted as held in memory for this tab only, a preset picker, a single-run or battle mode choice, a model picker reading gemini-3.1-flash-lite with free, 1000K context, tools, vision and its verification date under it, the prompt, and advanced controls with the calculator tool ticked, temperature 0.7, max tokens 512 and streaming off. On the right the answer 1827993, then model, tokens 163 in and 22 out, total 185, cost $0.000074 and latency 0.78s; a tool trace row reading calculator, the expression it was given and what it returned; and the run offered back as curl, CLI or Python, with the curl form shown.
One real run. The model picker carries each model’s price, context window and capabilities from the catalog; the tool checkboxes come from the registry the server has. The answer, the five figures under it and the trace row are what this run produced.GET /playground · one run of the prompt shown, with the calculator attached and streaming off

The tool trace is the run, not a summary of it

One trace row: the tool name calculator, the arguments it was called with, an arrow, the result 1827993, and the time it took on the right.
One row per tool step: the tool, the arguments it was called with, the result, and how long it took. Tools run server-side, so the trace shows what the agent actually did rather than what it said it did.the trace of the run above

Two modes, one prompt

Single run is one model answering. Battle sends the same prompt to every contender at once and lays the answers out side by side with each model’s own tokens, cost and latency — so a battle spends once per model, which the form says before you press Run.

The answer box appends deltas while a stream is open and marks itself busy for the duration, so a screen reader hears the answer once rather than hearing it re-read on every token. A battle grid is not a live region at all; the verdict is, and it is stated once.

Battle mode: two columns, gemini:gemini-3.1-flash-lite and openai:gpt-5-nano, each carrying that model's full answer to the same prompt with its own time to first token, total time, token count and cost. A verdict panel underneath names the fastest, the cheapest and the longest answer, and states that 2 of 2 answered in 4.74s for a total of $0.000423.
Battle mode with two contenders on one prompt, each column carrying that model’s own answer and its own measurements.battle mode with two contenders on one prompt

Copy this run

The three forms below are what the Copy button put on the clipboard after the run in the figure above — read back out of the page, then run. The model id, the tools, the temperature and the token cap are the ones the form was set to, so what you copy is the run you just watched.

curl

bash
curl http://127.0.0.1:8000/v1/chat/completions \
  -H 'Authorization: Bearer $EFFGEN_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"model":"gemini:gemini-3.1-flash-lite","messages":[{"role":"user","content":"What is 8347 * 219? Use the calculator."}],"temperature":0.7,"max_tokens":512,"tools":[{"type":"function","function":{"name":"calculator"}}]}'
the answer and the usage the server returned
1827993
usage: {'prompt_tokens': 163, 'completion_tokens': 22, 'total_tokens': 185}

CLI

bash
effgen run 'What is 8347 * 219? Use the calculator.' -m 'gemini:gemini-3.1-flash-lite' -t calculator --temperature 0.7 --max-tokens 512
what that command printed
╭─────────────────────────────── Agent Response ───────────────────────────────╮
│ 1827993                                                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
✓ Done in 2.7s · 1 tool · 183 tokens · $0.000073
1 tool step — run with --trace to see the timeline

Python

python
from effgen import Agent
from effgen.core.agent import AgentConfig
from effgen.tools import get_registry

reg = get_registry()

agent = Agent(AgentConfig(
    model="gemini:gemini-3.1-flash-lite",
    tools=[reg.get_tool_sync("calculator")],
    temperature=0.7,
    max_tokens=512
))
print(agent.run("What is 8347 * 219? Use the calculator."))
what that program printed
1827993

The Python form needs the provider key in the environment; the command-line form finds it the way effgen always does, and the curl form names the server’s own key as an environment variable rather than carrying one.

The model browser

417 models, searchable in the page

The catalog panel carries every model the catalog knows — context window, output limit, input and output price, and what each one can do — with a search box, provider and capability filters, a sort and paging. It is the same catalog effgen models browse reads.

The Model catalog panel, headed: 417 models across 9 providers, pricing from catalog snapshot verified 2026-06-08, run effgen models refresh to update. Under it a search box, a provider picker, a sort picker, tools, vision, audio and free checkboxes and a minimum-context field, then a table of provider, model id, context, max output, input and output price per million tokens, and tool and vision ticks. The pager reads 1 to 25 of 417.
417 models across 9 providers, with the snapshot the prices came from named above the table.GET /dashboard/catalog.json
The same panel with gemini typed into its search box: eleven rows, six Gemini models and two Gemma models from the gemini provider plus three google/gemini ids from replicate, each with its context window, output limit, prices and capability ticks. The pager reads 1 to 11 of 11, of 417.
The same panel with a search term typed into it.the catalog panel, filtered in the page

The providers behind that table, what each is good for, the pricing rules the panel follows, and the same catalog on the command line are on the models page.

Any model, anywhere
The topology graph

A team or a workflow, as the shape it ran in

Agents and the tools they reached are nodes; delegation, handoff and tool use are edges. It is built from the durable run store plus the buffered spans, so a team run from a script or the command line appears here too — not only work done inside the server process.

A node-link graph headed Agent topology, captioned: team newsroom, 2 nodes, 1 edge, $0.000061, 88 tokens. Two nodes, writer and editor, each showing its model and an ok glyph with the word ok beside it, joined by a dashed arrow. Above the graph, an execution picker and a legend naming ok, running, skipped, failed, agent and tool in words as well as in glyphs.
A two-agent team, run from a script while this server was up. Status is carried by a glyph and a word as well as by colour, nodes are keyboard-focusable and open a detail panel, and the picker above the graph switches between recorded executions.GET /dashboard/topology.json
bash
curl -s 'http://127.0.0.1:8000/dashboard/topology.json?limit=1' \
  | jq '.executions[0] | {id, kind, name, status, cost_usd, tokens, edges}'
what that returned
{
  "id": "ea8b59437040",
  "kind": "team",
  "name": "newsroom",
  "status": "ok",
  "cost_usd": 6.1e-05,
  "tokens": 88,
  "edges": [
    {
      "source": "writer",
      "target": "editor",
      "kind": "handoff",
      "count": 1
    }
  ]
}

executions is empty until something multi-agent has run — the panel says so rather than drawing an empty canvas. The graph is inline SVG the page builds itself; no graph library is involved.

Spans arrive as they happen

bash
curl -N -s http://127.0.0.1:8000/dashboard/spans | head -1
the first event, wrapped here to fit — one line on the wire
data: {"ts": "05:59:32", "name": "effgen.agent.run api:openai:gpt-9-does-not-exist", "kind": "agent", "agent": "api:openai:gpt-9-does-not-exist", "tool": null, "model": null, "duration_ms": 61.8, "status": "error", "error": "openai error (model='gpt-9-does-not-exist'): The model `gpt-9-does-not-exist` does not exist or you do not have access to it. Did you mean: gpt-5-mini, gpt-5-nano, gpt-4o-mini? ...", "note": null, "run_id": "20f57f459322", "offset_ms": 0.0, "execution_id": null, "execution_kind": null, "execution_name": null, "parent_agent": null, "role": null}

kind is agent, model, tool or router, and the matching field names what the span timed — read those rather than parsing name, which is the display label. A run that reports a failure without raising is recorded here as an error, which is how the timeline above knows to draw it as one.

Keyboard-first

One command palette, shared by both surfaces

Ctrl-K opens it on either page. It searches four groups built from data the page has already loaded, so it finds a stored run by its task text and a model by its capability without another request.

The command palette open over the dashboard with gemini typed into it. The results are grouped: a Models heading over catalogue ids with their provider on the right, then a Runs heading over stored runs matched on their task text, each showing the model that answered. The footer reads: up and down move, Enter run, Esc close, question mark shortcuts.
The palette with a query typed into it, grouped by where each match came from. The commands used most recently lead the list when it opens empty.Ctrl-K, then typing cost
The keyboard shortcut list: Ctrl-K opens the command palette; question mark shows this list; Escape closes the palette, this list, or an open detail; the up and down arrows move through palette results; Enter runs the highlighted command; and the four arrow keys move between topology nodes once one has focus. Escape closes the list.
? shows the whole keyboard layer. Escape closes the palette, this list, or an open detail pane.pressing ?

Navigate

every panel on this surface, plus the other surface

Actions

switch theme, refresh, clear or pause the span stream, focus a search box

Runs

stored runs, matched on task text, model, status or run id — selecting one opens its detail

Models

the catalog, matched on id, provider, family or capability — selecting one filters the table

Focus survives the five-second poll

Focus on a run’s disclosure button stays on that button when the poll rebuilds the history table, and the topology graph behaves the same way. If the run is no longer listed, focus moves to the history panel rather than to the top of the document.

A jump row under the header links to every panel. Selecting one — from the row or from the palette — scrolls to it and moves focus into it, so the next Tab continues from there.

Nothing is announced when nothing changed

Every value the page writes goes through a write-if-changed rule, so an idle dashboard is silent instead of re-reading five cards, the SLO line and the connection status every five seconds.

A sortable header is a button inside its <th>, exactly one header carries aria-sort, and the new order is spoken. Smooth scrolling is skipped for a visitor who prefers reduced motion, and the theme choice is stored under one key shared by both surfaces.

Self-contained

0 requests to anywhere but this machine

Both surfaces are 8 files inside the effgen package, 184 kB in total. There is no CDN script, no external stylesheet, no font host and no remote image, so the pages render the same on an air-gapped host as they do on a laptop. That is not a promise, it is a test.

FileTypeDescription
dashboard/static/index.html18,558 bytes
dashboard/static/app.js62,483 bytes
dashboard/static/style.css18,381 bytes
playground/static/index.html7,104 bytes
playground/static/app.js42,275 bytes
playground/static/style.css12,648 bytes
webui/static/webui.js21,641 bytes
webui/static/webui.css5,305 bytes

Shipped inside the package. The two webui files are the shared keyboard layer, served to both surfaces under the access rule of whichever page loaded them.

bash
# what the shipped files reference — the framework's own check
python -m pytest -q \
  "tests/dx/test_dashboard.py::TestStaticFiles::test_no_external_assets" \
  "tests/dx/test_playground.py::TestStaticFiles::test_no_external_network_hosts" \
  "tests/dx/test_web_palette.py::TestSharedAssets::test_no_external_asset_reference" \
  "tests/dx/test_dashboard.py::TestStaticFiles::test_latency_chart_drawn_locally"
what that printed
....                                                                     [100%]
4 passed in 3.76s

The first three scan every shipped file for an absolute or protocol-relative URL; a same-origin path such as /dashboard/data.json is fine, anything else fails the build. The fourth is why there is no chart library: the latency chart has to be drawn on a canvas.

And what a browser actually asked for

A file scan proves nothing about a page that builds a URL at run time, so the surfaces were also driven by a real browser with every request it made recorded. Loading /dashboard, waiting through a poll cycle and then loading /playground produced 23 requests, of which 0 left the server.

The one absolute URL anywhere in the shipped files is http://127.0.0.1:8000, printed inside the playground’s copy-as-curl snippet. It is text the page displays, not a request it makes.

Starting it

One command, and it is authenticated by default

effgen serve binds loopback, serves the OpenAI-compatible API, the dashboard and the playground from one application, and is never unauthenticated: with no key configured it mints an ephemeral one and prints it once.

bash
# local viewing: no auth, and the data routes open for the page
EFFGEN_DEV_MODE=1 EFFGEN_PUBLIC_DASHBOARD=1 effgen serve --port 8000

# then
open http://127.0.0.1:8000/dashboard
open http://127.0.0.1:8000/playground
curl -s http://127.0.0.1:8000/health
{"status":"ok","version":"1.0.0"}

--host 0.0.0.0 exposes it on every interface, which is why the help text tells you to set EFFGEN_API_KEY first. effgen top reads the same server’s /dashboard/data.json from a terminal.

FlagTypeDescription
--host HOSTHost to bind to (default 127.0.0.1, loopback-only). Pass --host 0.0.0.0 to expose on all interfaces (set EFFGEN_API_KEY first).
-p PORT, --port PORTPort to bind to
--rate-limit NRequests/minute per client IP (overrides EFFGEN_RATE_LIMIT; 0 disables). Health probes are always exempt.
--trust-proxyTrust the first X-Forwarded-For hop as the rate-limit client IP (overrides EFFGEN_TRUST_PROXY). Enable only behind a reverse proxy that sets/overwrites this header — otherwise any caller can spoof it to bypass the rate limit.

effgen serve --help · effGen 1.0.0

The rest of the surface is environment

VariableTypeDescription
EFFGEN_API_KEYstatic API key (Bearer or X-API-Key). If unset and not in dev mode, an ephemeral key is minted and printed once — never unauthenticated.
EFFGEN_DEV_MODE=1disable auth (loud warning; local dev only).
EFFGEN_RATE_LIMITrequests/minute per client IP (0 disables; health probes are always exempt). Or use --rate-limit.
EFFGEN_TRUST_PROXY=1trust the first X-Forwarded-For hop as the rate-limit client IP (default: off — the raw socket peer is used, since a caller can set that header to anything). Enable only behind a reverse proxy that sets/overwrites it.
EFFGEN_CORS_ORIGINScomma-separated allowed origins (default: none; cross-origin is fail-closed for a backend API).
EFFGEN_OIDC_ISSUER / EFFGEN_OIDC_CLIENT_IDenable OIDC/JWT auth instead of a static key.
EFFGEN_PUBLIC_METRICS=1serve /metrics without auth (default: auth).
EFFGEN_PUBLIC_DASHBOARD=1serve /dashboard/data.json + /dashboard/spans without auth, for local viewing (default: auth; the /dashboard page itself always loads, but its data calls 401 without this or an API key).
EFFGEN_MODEL_POOL_SIZEloaded models kept warm (default 4).
EFFGEN_NO_DOTENV=1skip the .env filesystem search entirely, so only environment variables the orchestrator set are visible (EFFGEN_DOTENV=none is equivalent).

effgen serve --help · effGen 1.0.0

When it will not show you anything

The page loads. The data does not.

The static shell is public so the page can render and ask for a key. The 5 data routes are authenticated by default and answer without one in the same typed envelope every other error uses — so a dashboard that shows nothing tells you why, instead of showing zeros that look like a quiet system.

The dashboard against a server started with no key configured. A banner across the top reads: dashboard data requires authentication, restart the server with EFFGEN_PUBLIC_DASHBOARD=1 for local viewing, or supply an API key. The status in the header reads Offline, and every card and burn-rate figure below shows a dash rather than a zero.
The same dashboard against a server started with no key configured. The banner names the environment variable that opens the routes for local viewing and the two headers a key can be sent in.the same page against a server started with no key configured
bash
curl -s http://127.0.0.1:8000/dashboard/data.json | python -m json.tool
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/dashboard/data.json
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/dashboard
what those three commands printed
{
    "error": {
        "message": "Missing API key (send 'Authorization: Bearer <key>' or 'X-API-Key: <key>')",
        "type": "invalid_request_error",
        "param": null,
        "code": "invalid_api_key"
    }
}
401
200

The page itself is 200 and the data is 401, which is the split that lets the shell render and prompt. EFFGEN_PUBLIC_DASHBOARD=1 opens the data routes for local viewing; in a shared deployment they stay closed and access is restricted at the ingress.

docs/server/auth.md

The reference for the web surfaces

What each panel measures, the shape of every response, the span and topology payloads, the keyboard table, the accessibility guarantees and the tests that hold them, and record_run for an integration that bypasses Agent.

docs/server/openai-compat.md — the API the same server serves
docs/dx/dashboard.md