Skip to content

[stats] Per-agent Stats tab — declarative card schema over a closed, owner-scoped source vocabulary #310

Description

@serge-ivo

Scoped 2026-08-07 after review. Two changes, detailed in the comment below:

  1. The platform-default cards are dropped. /v1/usage, the Activity tab, the Board and
    /v1/dashboard already cover tokens, cost, events and board counts. Re-rendering them per
    agent is polish. The value is the agent-declared domain metrics — "leads by suburb over
    time", "runs by outcome" — which nothing shows today.
  2. Trend cards are served from a daily rollup ([stats] Daily rollup — the only way to get a series for agent-defined collections (and a gap is not a zero) #313), not live aggregates. For agent-defined
    collections a snapshot is the only way to get a series at all, so this is load-bearing rather
    than an optimisation. Cards split into trend (rollup) and point-in-time (live).

The "Platform defaults" and "Cost" sections below are superseded by those two points; everything
else — cards as data, the closed source vocabulary, and the ownership rule — stands unchanged.


Each agent gets a Stats tab: graphs and the numbers that matter for that agent, configurable
by the subscriber, by MCP, or by the agent itself on request.

This is the core: the data model, the source vocabulary, and the resolver. Console rendering is
#(B), configuration surfaces are #(C).

Design principle — cards are data, not code

The house pattern is already established and should not be re-invented here: surfaces come from
capabilities.surfaces, tools from capabilities.tools + the registry, subscriber settings from
settingsSchema (sanitizeSettingsSchema, capped at MAX_SETTINGS_FIELDS), board columns from
capabilities.boardColumns, character from BEHAVIOUR_FIELDS.

A stats page built as a hardcoded dashboard would be the same mistake as the technicality heuristic
and the tmux prose: behaviour asserted in code that drifts from what the agent actually is.

So: statsSchema is a table of card definitions, sanitized and capped, resolved the same way
behaviour is — creator default (agents.config.statsSchema) merged per card under the
subscriber's override (agent_instances.config.stats). Unset is a first-class state; an agent that
declares nothing still gets the platform defaults below.

The security boundary: a closed source vocabulary

A card names a source from a registry. It never carries a query.

{ id: "leads_by_suburb", title: "Leads by suburb", kind: "bar",
  source: "collection.group_by", params: { collection: "leads", field: "suburb", limit: 10 } }

Each source is a hand-written, owner-scoped aggregate — the same shape as the connector registry
({id,label,auth,scopes,tools}) and the step registry:

source backed by
usage.tokens / usage.cost ai_usage
runs.count / runs.outcome agent_loop_runs
pipeline.runs pipeline_runs
board.by_column instance_runtime_tasks
events.by_type agent_events
triggers.fires agent_trigger_events
collection.count / collection.group_by the instance's own collections

Every source bakes in WHERE instance_id = ? AND user_id = ?. The card supplies which view, never
whose data.

This is the sentence that makes agent-authored stats safe: the schema says WHAT to show, never
WHOSE data. Same discipline as #224 — an agent may add a card, exactly as it may set its tone; it
may not widen scope, exactly as it may not widen a guardrail. A prompt-injected Repo Coder can at
worst produce a useless card.

Anything resembling free-form SQL in a card is out of scope and should stay out.

Platform defaults, so the tab is useful before anyone configures it

Every instance already has usage, events, runs and a board. The default set (shown when nothing is
declared) should be roughly: tokens + cost over the window, runs by outcome, activity by type,
board items by column. An agent's declared cards then add domain meaning on top — the lead-finder's
116-record leads collection is the obvious first real example.

Cost is a design constraint, not an afterthought

These are aggregates over the busiest tables in the product. The Usage page already meets this
problem. Requirements:

  • One page-level time window (7d / 30d / 90d), not per card — so a view is one query set.
  • Bounded cards (cap the count, cap limit per card) — cf. [bug] slice with an unparseable limit silently drops every record, and the run still reports success #243, where an unparseable limit
    silently dropped every record; parse windows and limits with Number.isFinite, and treat absent
    and unparseable differently.
  • Indexes checked per source before it ships, and a cached response. A stats tab must not become
    the most expensive query in the platform, especially on a per-agent page someone leaves open.

Verification

  • A card naming an unknown source is rejected at save time, by name (cf. validateConnectionFilter,
    which rejects a never-matching filter up front rather than letting it fail silently forever).
  • No source can be made to read another user's or another instance's rows, including via params.
  • An agent with no statsSchema sees the platform defaults; declaring one adds to them.
  • Each source has a measured query plan against a realistic row count.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions