---
name: outputcharts
description: Build and keep updated a shareable Output Charts dashboard from any data; sign up for the person if they have no account
---

# Output Charts for agents

Output Charts turns JSON into a dashboard with a stable public link. You build the dashboard with one HTTP call, hand the person the link, and keep it fresh with pushes from a script, a cron job or your own next run. The page redraws within 15 seconds of a push; the link never changes.

Base URL: `https://api.outputcharts.com`. Every call sends and returns JSON (`Content-Type: application/json`), and every call but sign-up and the two public reads carries `Authorization: Bearer <key>`. The OpenAPI document is `GET /v1/openapi.json`; the visual types with their document rules, an example of each and their size limits are `GET /v1/schema`. Neither needs a key.

## When to use this

- The person asked you for a dashboard, a chart, a report, a status page or "a link I can send": numbers they want to look at more than once.
- The numbers already exist in something you can run: a log, a CSV, an API, a CI job, a spreadsheet export.
- They want the thing to stay current without you rebuilding it every time.

Do not use it for a one-off answer in chat. There are two ways to give people access, and step 2 takes either or both: publish, which opens the page to anyone with the link (not indexed by search engines unless the owner lists it), or share by email, which gives named people view access after they sign in. Pick the one that matches what the person asked for; data that only certain people should see is shared, not published.

## The flow

### 1. No key yet: sign the person up

One call, no key, with the person's email address:

```bash
curl -X POST https://api.outputcharts.com/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"person@example.com","agentName":"Claude Code","useCase":"PR queue dashboard"}'
```

It answers `201` with:

- `apiKey.secret`: a Manage key, shown once. Keep it for the rest of the session and send it as the bearer token on every call. Never print it in a public place.
- `apiKey.mcpUrl`: the same key as an MCP connector URL, for chat apps that cannot send a header.
- `claimUrl`: give this to the person in your reply. It opens the page where they sign in (Google, or a password they set) and take the account over. They also get an email with the same link (`claimEmailSent` is false when none could be sent: then the link in your reply is the only one). They must open it within 24 hours of sign-up: from then on every push answers `403 claim_required`, with `claimUrl` and a `fix`, until they do, and resumes the moment they do.
- `account`: `tier: "paid"`, `trial: true`, `trialEndsAt`: the account is on a seven-day Solo trial from this minute, no card. Everything below works at once. The trial is once per person: a mailbox that already had one (plus tags and Gmail dots count as the same mailbox), or a second sign-up from one network within 30 days, gets `tier: "free"`, `trial: false` and a top-level `fix` instead; the account and the key exist, pushes answer `403 requires_solo`, and the way forward is section 5. Do not try another address to get a trial.
- `unclaimedUntil`: an account nobody claims and nobody pays for is deleted 30 days after sign-up, dashboards included. Say so to the person.

Refusals: `409 email_taken` when the address already has an account (its `fix` says to hand the owner the whole dashboard as one JSON block to import, Workspace → Import JSON, or on Solo to make a key under Account → API keys; do not try another spelling of their address); `429 rate_limited` after 5 sign-ups from one IP or 300 in all per UTC day; `400 invalid_request` for a malformed address or an `agentName` with characters other than letters, digits, spaces and `. , ' & ( ) + _ -`.

If the person already has a key, skip this step. If they have an account but no key (Free has none), skip the API: give them the whole dashboard as one JSON block in your reply, the body of step 2, and tell them to sign in and click Import JSON. `GET /v1/me` tells you what a key is and what the account may do.

### 2. Build the dashboard in one call

`POST /v1/dashboards` with the visuals and `publish: true` makes the dashboard, every visual in order, and the public page, and answers `201` with `dashboard.url`. Hand that URL to the person.

Two ways to give people access, in the same call or later, with or without each other:

- `"publish": true`: the page at `dashboard.url` opens for anyone with the link. Later: `POST /v1/dashboards/{id}/publish` and `.../unpublish`.
- `"shareWith": ["colleague@example.com", …]`: each person gets an email with the link to the shared page and view-only access once they sign in with that address (an account is made for them at sign-in if they have none); the dashboard can stay private. The answer's `shares` lists one result per address with the page's `url`, and the same address on a re-run answers `created: false` with no second email. Later: `POST /v1/dashboards/{id}/shares` with `{ "email" }`, `GET .../shares` to see them, `DELETE .../shares/{shareId}` to revoke; at most 20 per dashboard.

- Give the dashboard and every visual a `ref` (1 to 64 of `a-z`, `0-9`, `-`, unique per owner for dashboards and per dashboard for visuals). A later run with the same body answers `200` with `created: false` and the same objects, so re-running never makes duplicates, and a run cut short by an error is finished by the next.
- Each visual is `{ "type", "title", "ref", "document", "size" }`. `document` is the visual's whole JSON (settings and data together), valid for its `type`; the rules per type are in `GET /v1/schema`, and the worked examples below are valid as written. `size` is `{ "w", "h" }`: `w` in columns of a 12-column grid, `h` in 50-pixel rows, each type with its own minimums: a KPI or markdown is at least 3 by 3, a table at least 4 by 4, every chart and the gauge at least 3 wide and 4 tall; the maximum is 12 by 16 for all. A size outside that is `400` with a `fix` naming the type's limits. Leave `size` out for the type's default (6 by 3 for a KPI, 6 by 8 for a chart or a table).
- Everything is checked before anything is written. A bad document answers `422 invalid_document` with `details` (each a `path` such as `visuals[2].document.data[0].users` and a `message`), a `fix` sentence and an `example` document of that type; nothing exists afterwards. `"dryRun": true` answers `200 { "dryRun": true }` or that error, writing nothing.
- Limits: 20 dashboards per account on Solo, 24 visuals per dashboard, documents up to 100 KB, 500 rows per chart and 200 per table, 60 calls a minute per key.

### 3. Keep it fresh

Three ways, all with a Push or Manage key, all answering `coerced` (the numeric strings that became numbers, at most 20 listed) and `coercedCount`:

- Replace one visual: `PUT /v1/visuals/{id}/data` with the new document as the body.
- Append rows to a chart or a table: `POST /v1/visuals/{id}/rows` with `{ "rows": [ … ], "keepLast": 90 }`. The visual's settings stay; the rows go on the end and the oldest fall off past `keepLast`. Two appends at the same moment lose nothing.
- Update several visuals at once: `PUT /v1/dashboards/{id}/data` with `{ "<ref>": <document>, … }`. Every entry is tried; `200` when all were written, `422 batch_failed` with per-ref `results` otherwise.

Both the replace and the append also take `Content-Type: text/csv`: the header row is the keys the visual's rows have (`date,users`), and on the append `keepLast` goes in the query string (`?keepLast=90`). A header with a key the visual does not have is `422` with a `fix` naming the keys it expects; no column is dropped in silence.

Numbers in strings are read for you at the fields that must be numbers: `"1,234.5"`, `"$12"`, `"(3)"`, `"1 000"` become numbers; `"25%"` becomes `0.25` in a field the page shows as a percent and `25` elsewhere; in a table's number cells `N/A`, `-` and blank become `null`. Dates and labels are never rewritten: they display as sent, so send them sorted and in one format.

Visual ids come back in the create's `visuals[]` (one per entry, in order) and from `GET /v1/dashboards/{id}`. Make calls to one dashboard one at a time. No call deletes a dashboard.

### 4. A key for CI or a cron

`POST /v1/keys` with a Manage key makes a narrower one: `{ "name": "CI", "scope": "push", "dashboardId": "<the dashboard>" }` answers `201` with a key that can only send data to that dashboard's visuals, shown once. That is the key to put in a repository secret or a crontab; keep the Manage key out of them. Five live keys per account.

### 5. When the trial ends

The trial lasts seven days. After it, any call but two answers:

```json
{
  "error": "The agent API is part of Solo",
  "code": "requires_solo",
  "upgradeUrl": "https://outputcharts.com/account/billing",
  "fix": "The account's Solo trial has ended or it is on Free: call POST /v1/billing/checkout (the MCP tool get_checkout_link) for a Stripe Checkout link, give it to the account's owner, and calls work again as soon as they pay."
}
```

Nothing is deleted: the dashboards stay readable, the public pages stay up, the keys pause. An account that already pays answers `409 already_subscribed` to the checkout call (nothing to pay; every call works). Do what the `fix` says: `POST /v1/billing/checkout` (Manage key; `{ "interval": "monthly" | "yearly", "promotionCode"?: "…" }`, the code applied when given one) answers `{ "checkoutUrl", "sessionId" }`. Give `checkoutUrl` to the person; it is a hosted Stripe page. The moment they pay, every call works again. `GET /v1/me` keeps working too and says `owner.tier` (`free` after the trial), `owner.trial`, `owner.trialEndsAt`, `owner.upgradeUrl` and, on an account whose trial was withheld, `owner.fix`, so you can tell the person before a push fails.

A different 403, `claim_required`, means the person has not claimed the account within 24 hours of sign-up. Its body carries `claimUrl`, `upgradeUrl` and a `fix`; nothing is deleted, `GET /v1/me` and the checkout still answer, and every call works again the moment the person opens the claim link. Give them `claimUrl` again and wait; do not sign them up anew.

### 6. Over MCP instead of HTTP

The same API is an MCP server. With no key, `https://api.outputcharts.com/mcp` serves two tools, `signup` and `describe_visual_types`; after `signup`, reconnect with the key as a bearer token at the same URL (Claude Code: `claude mcp add --transport http output-charts https://api.outputcharts.com/mcp --header "Authorization: Bearer $OC_KEY"`) or add `apiKey.mcpUrl` as a connector in a chat app. The tools are the calls above by name: `create_dashboard`, `push_visual_data`, `append_rows`, `batch_update`, `create_api_key`, `get_checkout_link`, `whoami`.

## Worked examples

Each body below is a complete `POST /v1/dashboards` call: send it as is (with the person's titles and numbers), get `dashboard.url` back, then keep it fresh as shown. The refs are what the batch bodies key on.

### Pull request queue, refreshed by GitHub Actions every hour

Which PRs are waiting and for how long. A workflow lists open PRs with `gh pr list --json number,title,author,createdAt` and pushes.

```json
{
  "title": "Pull request queue",
  "ref": "pr-queue",
  "publish": true,
  "visuals": [
    {
      "type": "kpi",
      "title": "Oldest open PR",
      "ref": "oldest-pr",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Oldest open PR", "value": 9, "format": "number", "suffix": " days" }
    },
    {
      "type": "kpi",
      "title": "Open PRs",
      "ref": "open-count",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Open PRs", "value": 3, "format": "number", "delta": -2, "deltaFormat": "number", "deltaLabel": "vs yesterday" }
    },
    {
      "type": "table",
      "title": "Waiting for review",
      "ref": "open-prs",
      "size": { "w": 12, "h": 8 },
      "document": {
        "columns": [
          { "key": "pr", "label": "PR", "align": "left", "format": "text" },
          { "key": "title", "label": "Title", "align": "left", "format": "text" },
          { "key": "author", "label": "Author", "align": "left", "format": "text" },
          { "key": "days", "label": "Open (days)", "align": "right", "format": "number" }
        ],
        "data": [
          { "pr": "#212", "title": "Retry failed webhook deliveries", "author": "sam", "days": 9 },
          { "pr": "#219", "title": "Move billing emails to the queue", "author": "ana", "days": 4 },
          { "pr": "#223", "title": "Fix timezone in weekly report", "author": "sam", "days": 1 }
        ]
      }
    }
  ]
}
```

The queue is the current state, so every hour the workflow replaces all three in one batch, `PUT /v1/dashboards/{id}/data`, with a Push key bound to the dashboard (step 4) in the repository's secrets:

```json
{
  "oldest-pr": { "label": "Oldest open PR", "value": 10, "format": "number", "suffix": " days" },
  "open-count": { "label": "Open PRs", "value": 4, "format": "number", "delta": 1, "deltaFormat": "number", "deltaLabel": "vs yesterday" },
  "open-prs": {
    "columns": [
      { "key": "pr", "label": "PR", "align": "left", "format": "text" },
      { "key": "title", "label": "Title", "align": "left", "format": "text" },
      { "key": "author", "label": "Author", "align": "left", "format": "text" },
      { "key": "days", "label": "Open (days)", "align": "right", "format": "number" }
    ],
    "data": [
      { "pr": "#212", "title": "Retry failed webhook deliveries", "author": "sam", "days": 10 },
      { "pr": "#219", "title": "Move billing emails to the queue", "author": "ana", "days": 5 },
      { "pr": "#223", "title": "Fix timezone in weekly report", "author": "sam", "days": 2 },
      { "pr": "#231", "title": "Add a retry budget to the worker", "author": "kim", "days": 0 }
    ]
  }
}
```

### AI coding spend and cache share, from a usage export

Which model is eating the budget, and how much input is served from the prompt cache. The source is a usage CSV (`date,model,input_tokens,cached_tokens,output_tokens,cost_usd`) summed per day and per model.

```json
{
  "title": "AI coding spend",
  "ref": "ai-spend",
  "publish": true,
  "visuals": [
    {
      "type": "kpi",
      "title": "Input served from cache",
      "ref": "cache-share",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Input served from cache", "value": 0.976, "format": "percent" }
    },
    {
      "type": "kpi",
      "title": "Spend this month",
      "ref": "month-spend",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Spend this month", "value": 41.3, "format": "number", "prefix": "$" }
    },
    {
      "type": "line",
      "title": "Daily cost by model",
      "ref": "daily-cost",
      "size": { "w": 12, "h": 7 },
      "document": {
        "xKey": "date",
        "curve": "monotone",
        "series": [{ "key": "model-a", "label": "model-a" }, { "key": "model-b", "label": "model-b" }],
        "data": [
          { "date": "2026-10-12", "model-a": 3.1, "model-b": 0.4 },
          { "date": "2026-10-13", "model-a": 3.9, "model-b": 0.6 },
          { "date": "2026-10-14", "model-a": 2.7, "model-b": 0.9 }
        ]
      }
    },
    {
      "type": "bar-horizontal",
      "title": "Cost by model, 30 days",
      "ref": "cost-by-model",
      "size": { "w": 6, "h": 6 },
      "document": {
        "orientation": "horizontal",
        "mode": "grouped",
        "xKey": "model",
        "series": [{ "key": "cost", "label": "Cost (USD)" }],
        "data": [{ "model": "model-a", "cost": 31.4 }, { "model": "model-b", "cost": 9.9 }]
      }
    }
  ]
}
```

Each day the script appends one row to the line, keeping the last 60 days, with `POST /v1/visuals/{daily-cost id}/rows`:

```json
{ "rows": [{ "date": "2026-10-15", "model-a": "3.40", "model-b": "0.70" }], "keepLast": 60 }
```

and replaces the two KPIs and the bar with a batch, `PUT /v1/dashboards/{id}/data`:

```json
{
  "cache-share": { "label": "Input served from cache", "value": "97.9%", "format": "percent" },
  "month-spend": { "label": "Spend this month", "value": "$45.60", "format": "number", "prefix": "$" },
  "cost-by-model": {
    "orientation": "horizontal",
    "mode": "grouped",
    "xKey": "model",
    "series": [{ "key": "cost", "label": "Cost (USD)" }],
    "data": [{ "model": "model-a", "cost": 34.8 }, { "model": "model-b", "cost": 10.6 }]
  }
}
```

(`"97.9%"` is stored as `0.979` because the KPI's format is percent; `"$45.60"` becomes `45.6`. Both are listed in `coerced`.)

### Page speed report for a client

The numbers a client wants from a performance engagement, as one link that stays current after every Lighthouse run. On Solo the public page shows the dashboard alone, with no Output Charts bar.

```json
{
  "title": "Store speed report",
  "ref": "client-speed-report",
  "publish": true,
  "scheme": "paper",
  "visuals": [
    {
      "type": "kpi",
      "title": "Home p75 LCP",
      "ref": "home-lcp",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Home p75 LCP", "value": 3.4, "format": "number", "suffix": " s", "delta": -0.8, "deltaFormat": "number", "deltaLabel": "vs before the fixes" }
    },
    {
      "type": "kpi",
      "title": "Home weight",
      "ref": "home-weight",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Home page weight", "value": 1960, "format": "compact", "suffix": " KB" }
    },
    {
      "type": "bar-horizontal",
      "title": "Page weight before and after",
      "ref": "page-weight",
      "size": { "w": 6, "h": 6 },
      "document": {
        "orientation": "horizontal",
        "mode": "grouped",
        "xKey": "template",
        "series": [{ "key": "before", "label": "Before (KB)" }, { "key": "after", "label": "After (KB)" }],
        "data": [
          { "template": "Collection", "before": 3120, "after": 1840 },
          { "template": "Product", "before": 2480, "after": 1610 },
          { "template": "Home", "before": 1960, "after": 1390 }
        ]
      }
    },
    {
      "type": "table",
      "title": "Slowest routes",
      "ref": "slow-routes",
      "size": { "w": 6, "h": 6 },
      "document": {
        "columns": [
          { "key": "route", "label": "Route", "align": "left", "format": "text" },
          { "key": "lcp", "label": "p75 LCP (s)", "align": "right", "format": "number" },
          { "key": "weight", "label": "Weight (KB)", "align": "right", "format": "number" },
          { "key": "fix", "label": "Planned fix", "align": "left", "format": "text" }
        ],
        "data": [
          { "route": "/collections/all", "lcp": 4.9, "weight": 3120, "fix": "Lazy-load below-fold product images" },
          { "route": "/products/*", "lcp": 4.1, "weight": 2480, "fix": "Preload hero image, drop unused app script" },
          { "route": "/", "lcp": 3.4, "weight": 1960, "fix": "Serve hero as WebP" }
        ]
      }
    }
  ]
}
```

After each run, replace the KPI alone with `PUT /v1/visuals/{home-lcp id}/data`, the body being the document:

```json
{ "label": "Home p75 LCP", "value": 2.9, "format": "number", "suffix": " s", "delta": -1.3, "deltaFormat": "number", "deltaLabel": "vs before the fixes" }
```

or the table from the run's CSV with `Content-Type: text/csv` on the same call (the header must be `route,lcp,weight,fix`; the columns stay as they are).

### Flow metrics for a team's coding agents

Throughput, lead time, work in progress and what is waiting on a person, computed from the agents' logs by a script that runs after each sync.

```json
{
  "title": "Agent flow",
  "ref": "agent-flow",
  "publish": true,
  "visuals": [
    {
      "type": "kpi",
      "title": "Lead time, median",
      "ref": "lead-median",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Lead time, median", "value": 5.2, "format": "number", "suffix": " h", "delta": -1.1, "deltaFormat": "number", "deltaLabel": "vs last week" }
    },
    {
      "type": "kpi",
      "title": "Lead time, p90",
      "ref": "lead-p90",
      "size": { "w": 4, "h": 3 },
      "document": { "label": "Lead time, p90", "value": 19.5, "format": "number", "suffix": " h" }
    },
    {
      "type": "gauge",
      "title": "Stories in flight",
      "ref": "wip",
      "size": { "w": 4, "h": 4 },
      "document": { "label": "Stories in flight", "value": 7, "min": 0, "max": 10 }
    },
    {
      "type": "area",
      "title": "Cumulative flow",
      "ref": "cfd",
      "size": { "w": 8, "h": 7 },
      "document": {
        "xKey": "day",
        "curve": "step",
        "stacked": true,
        "series": [
          { "key": "merged", "label": "Merged" },
          { "key": "review", "label": "In review" },
          { "key": "building", "label": "Building" },
          { "key": "queued", "label": "Queued" }
        ],
        "data": [
          { "day": "Oct 14", "merged": 3, "review": 2, "building": 4, "queued": 6 },
          { "day": "Oct 15", "merged": 6, "review": 3, "building": 4, "queued": 5 },
          { "day": "Oct 16", "merged": 11, "review": 2, "building": 5, "queued": 3 }
        ]
      }
    },
    {
      "type": "table",
      "title": "Waiting on a person",
      "ref": "stuck",
      "size": { "w": 4, "h": 7 },
      "document": {
        "columns": [
          { "key": "story", "label": "Story", "align": "left", "format": "text" },
          { "key": "hours", "label": "Stuck (h)", "align": "right", "format": "number" },
          { "key": "waiting", "label": "Waiting on", "align": "left", "format": "text" }
        ],
        "data": [
          { "story": "Story 41", "hours": 9, "waiting": "Approve schema change" },
          { "story": "Story 38", "hours": 6, "waiting": "Flaky test" }
        ]
      }
    }
  ]
}
```

Daily: append the day's counts to the flow chart (`POST /v1/visuals/{cfd id}/rows`, keeping 30 days) and batch the rest:

```json
{ "rows": [{ "day": "Oct 17", "merged": 14, "review": 3, "building": 4, "queued": 2 }], "keepLast": 30 }
```

```json
{
  "lead-median": { "label": "Lead time, median", "value": 4.8, "format": "number", "suffix": " h", "delta": -0.4, "deltaFormat": "number", "deltaLabel": "vs last week" },
  "lead-p90": { "label": "Lead time, p90", "value": 17.2, "format": "number", "suffix": " h" },
  "wip": { "label": "Stories in flight", "value": 6, "min": 0, "max": 10 },
  "stuck": {
    "columns": [
      { "key": "story", "label": "Story", "align": "left", "format": "text" },
      { "key": "hours", "label": "Stuck (h)", "align": "right", "format": "number" },
      { "key": "waiting", "label": "Waiting on", "align": "left", "format": "text" }
    ],
    "data": [{ "story": "Story 41", "hours": 21, "waiting": "Approve schema change" }]
  }
}
```

## Rules that save a call

- Every row of a chart needs the x key and every series key; put `0`, not nothing, where a day had no value. A line or area takes 1 to 6 series; a bar takes `orientation` (`vertical` for `bar`, `horizontal` for `bar-horizontal`) and `mode` (`grouped` or `stacked`).
- A KPI is `{ label, value, format }` plus optional `prefix`, `suffix`, `delta`, `deltaFormat`, `deltaLabel`; `format` is `number`, `compact` or `percent`, and a percent value is a fraction (`0.25`). A gauge is `{ label, value, min, max }`. A table's `columns` carry `key`, `label`, `align` (`left` or `right`) and `format` (`text`, `number`, `percent`).
- Titles up to 120 characters, labels up to 80, table cells up to 200. `scheme` is `ink` (default), `carbon`, `paper`, `ledger`, `slate` or `signal`.
- Treat `fix` as the next instruction and `example` as the shape to copy. A `429` carries `Retry-After` in seconds (the next UTC minute). A `409 conflict` means two calls changed one dashboard at once: send the call again, one at a time.
- Tell the person four things when you hand over the link: the URL; that the claim link in their email (or `claimUrl`) makes the account theirs and must be opened within 24 hours, or updates pause until it is; that the trial is seven days, once per person; and that the account is deleted after 30 days if nobody claims or pays.
