# Keep a dated report of a gateway view

Turn a saved gateway view into a report run that keeps one dated answer, then open it, run it again and export it.

Source: https://tokentriage.com/docs/build/create-gateway-report

---
import CodeTabs from '../../../components/CodeTabs.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Somebody asks how the checkout gateway did yesterday. You have a saved view that answers it,
but a view asks again every time it opens, so by Wednesday it answers a different question.
You want Monday's answer, with the window it covered, kept where you and they can open it.

In this tutorial we'll turn a saved view into a report, wait for its first run to finish, open
the answer, run it again for a fresh window, and export one run as CSV. By the end you'll have
two dated runs of the same question side by side, each with its own window and digest.

[What a report run keeps, and who can open it](/docs/concepts/report-snapshots-and-access)
explains why a run keeps its answer; this page only uses that.

Every output block on this page is program output, captured by replaying the recorded answers in
`sdk/spec/report-read-fixtures.json` through a loopback server, so `--base-url` names `127.0.0.1`
and a port that changes on every run. Yours names your deployment. Those answers were recorded
from the control plane's report handler, service and worker; the analytics answer inside each
run comes from a test reader that stands in for the analytics service. The CLI writes a line
naming each request to stderr, and the blocks keep it where a terminal shows it.

## Before you start

| You need | Why |
|---|---|
| the `tokentriage` CLI and your deployment's origin for `--base-url` | every step is one `cloud report` command |
| a management key in `TOKENTRIAGE_MANAGEMENT_KEY` | reports are read with a person's or a management credential |
| an organization ID (`org_…`) and a workspace ID (`t_…`) | reports belong to one workspace |
| a saved view you can open (`sview_…`) | the report copies its question |
| `metadata.read` on the view's whole scope | a narrower block is answered as not found |

You don't need `cost.read`. Without it, money cells arrive as unavailable rather than as
numbers, and the rest of the report is unchanged. You don't need a runtime key, and nothing here
calls a model provider. If you have no saved view yet,
[Save a gateway view and share it](/docs/build/save-gateway-views) makes one.

## 1. Read the view's revision

A report pins the revision of the view you read, so a view somebody edits while you type is
caught instead of reported over a different question. Read the view with
`tokentriage cloud view get --org … --view …` and note the revision it prints:

```console
$ tokentriage cloud view get --org org_K11111111111 --view sview_K11111111111 \
    --base-url http://127.0.0.1:13115
cloud: customer.saved_views.get GET http://127.0.0.1:13115/portal/api/v1/customer/v1/organizations/org_K11111111111/saved-views/sview_K11111111111
sview_K11111111111	Checkout gateway, last 24h	personal	relative	overview	revision 1
cloud: this is the stored QUESTION and not its answer; `cloud view open` answers it under your own scope
```

Replayed from the recorded case `report-view-get`: a view saved at revision 1 that asks about
one checkout gateway over a rolling 24 hours.

The revision is a decimal string. Copy it as it is printed.

## 2. Create the report

Write the report document to `report.json`. It names the view and the revision you read, and
lists nobody else yet:

```json
{
  "name": "Gateway daily report",
  "saved_view_id": "sview_…",
  "expected_view_revision": "1",
  "recipient_subject_ids": [],
  "evidence_refs": []
}
```

Create the report with `tokentriage cloud report create --org … --workspace … --file report.json
--idempotency-key gateway-daily-report-01`. On stdout, the first line is the definition and the
second is its first run, which starts `queued`:

```console
$ tokentriage cloud report create --org org_K11111111111 --workspace t_K11111111111 \
    --file report.json --idempotency-key gateway-daily-report-01 \
    --base-url http://127.0.0.1:13117
cloud: customer.reports.create POST http://127.0.0.1:13117/portal/api/v1/customer/v1/organizations/org_K11111111111/workspaces/t_K11111111111/reports (Idempotency-Key gateway-daily-report-01)
rpt_W11111111111	Gateway daily report	view sview_K11111111111@1	latest run rrun_W11111111111
rrun_W11111111111	queued	[2026-09-20T12:00:00Z, 2026-09-21T12:00:00Z)	groups 0	
```

Replayed from `report-create`, recorded with the service clock at 2026-09-21T12:00Z, which is
where the run's window ends.

Keep the `rpt_…` and `rrun_…` IDs; every later step uses them. If the command times out, send
it again with the same `--idempotency-key`. The server answers with the report it already made
instead of a second one.

<Details summary="If the create answers HTTP 412 precondition_failed">

Somebody edited the view after you read it. Nothing was created, and the refusal names both
revisions:

```console
$ tokentriage cloud report create --org org_K11111111111 --workspace t_K11111111111 \
    --file report.json --idempotency-key gateway-daily-report-03 \
    --base-url http://127.0.0.1:9272
cloud: customer.reports.create POST http://127.0.0.1:9272/portal/api/v1/customer/v1/organizations/org_K11111111111/workspaces/t_K11111111111/reports (Idempotency-Key gateway-daily-report-03)
Error: customer.reports.create was refused as a stale revision (HTTP 412); detail: foundation: stale revision: saved view sview_K11111111111 is at revision 2 and the report expected 1; type=precondition_failed; request_id=req_report-create-stale-revision
```

Replayed from `report-create-stale-revision`, recorded after the view was edited to revision 2,
with a new key: the same key and body would have answered the first report instead of checking
the view again.

Read the view again, look at what changed, and create the report with the new revision.

</Details>

<Details summary="If the create answers HTTP 503 dependency_unavailable">

This deployment has no report worker, store or reader running, so a report would be saved but
would not run. Nothing was created. Your saved view still opens. Ask whoever runs the deployment
to enable reporting, then create the report again with the same key.

```console
$ tokentriage cloud report create --org org_K11111111111 --workspace t_K11111111111 \
    --file report.json --idempotency-key gateway-daily-report-01 \
    --base-url http://127.0.0.1:13196
cloud: customer.reports.create POST http://127.0.0.1:13196/portal/api/v1/customer/v1/organizations/org_K11111111111/workspaces/t_K11111111111/reports (Idempotency-Key gateway-daily-report-01)
Error: customer.reports.create was refused (HTTP 503); detail: this control plane composes no report store, analytics reader and report worker, so no report can be saved, run or opened here; the request id identifies this attempt in its logs; type=dependency_unavailable; request_id=req_report-create-unavailable
```

Replayed from `report-create-unavailable`, recorded against a control plane that composes no
reports. The server sent no `Retry-After` header with this 503.

</Details>

## 3. Wait for the run to settle

A worker picks the run up and answers the view's question for the window it resolved when the
run was created. Poll it with `tokentriage cloud report show --org … --report rpt_… --run rrun_…`
until its state is `ready`, `partial`, `failed` or `cancelled`:

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_W11111111111 --base-url http://127.0.0.1:13217
cloud: customer.reports.runs.get GET http://127.0.0.1:13217/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_W11111111111
rrun_W11111111111	queued	[2026-09-20T12:00:00Z, 2026-09-21T12:00:00Z)	groups 0	
```

Replayed from `report-show-queued`, recorded before any worker had claimed the run.

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_W11111111111 --base-url http://127.0.0.1:13219
cloud: customer.reports.runs.get GET http://127.0.0.1:13219/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_W11111111111
rrun_W11111111111	ready	[2026-09-20T12:00:00Z, 2026-09-21T12:00:00Z)	groups 1	sha256:ceccd02ca24b008b84416282a088f37133e7d8646e830c54de3d84a2f01284d0
```

Replayed from `report-show-ready`, recorded after one worker pass and opened by the report's
owner, who holds `cost.read`.

The line gives the run's state, its half-open UTC window `[from, to)`, the number of groups and
the artifact digest. Coverage reasons, truncation and withheld money are stated on stderr, under
the line they qualify.

<Details summary="If the run ends failed">

A failed run keeps no answer, and the second line names the reason. `scope_unavailable` means
the access of the person who started the run no longer covered its scope when it ran;
`output_cap_exceeded` means the answer passed 1,000 groups or 10 MiB; `attempts_exhausted`
means five transient failures in a row. Fix the cause, then run the report again as in step 5.
The failed run stays as it is.

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_Y11111111111 \
    --run rrun_Y11111111111 --base-url http://127.0.0.1:13221
cloud: customer.reports.runs.get GET http://127.0.0.1:13221/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_Y11111111111/runs/rrun_Y11111111111
rrun_Y11111111111	failed	[2026-09-20T12:00:00Z, 2026-09-21T12:00:00Z)	groups 0	
failed	output_cap_exceeded	the answer holds more than 1000 rows
```

Replayed from `report-show-failed`, recorded for a second report over the same view whose test
reader answered 1,001 groups.

</Details>

<Details summary="If show answers HTTP 404 not_found">

The server does not tell you whether the report is missing or hidden from you. Check the IDs and
the organization. Then check that you own the report or are a recipient, and that your
`metadata.read` still covers the view's whole scope.

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_W11111111111 --base-url http://127.0.0.1:13223
cloud: customer.reports.runs.get GET http://127.0.0.1:13223/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_W11111111111
Error: customer.reports.runs.get was refused as not found (HTTP 404): the lane conceals organizations and resources the caller has no binding for, so this can mean it does not exist OR that you cannot see it; detail: foundation: not found: no report rpt_W11111111111; type=not_found; request_id=req_report-show-not-found
```

Replayed from `report-show-not-found`, recorded as a workspace administrator who is neither the
owner nor a recipient. An ID that does not exist gets the same answer.

</Details>

## 4. Open the answer

The same command with `--json` prints the whole run document, including the answer, the
coverage and the two digests:

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_W11111111111 --json --base-url http://127.0.0.1:13225
cloud: customer.reports.runs.get GET http://127.0.0.1:13225/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_W11111111111
{
  "schema": "customer/v1",
  "request_id": "req_report-show-json",
  "kind": "opened-report-run",
  "data": {
    "run_id": "rrun_W11111111111",
    "report_id": "rpt_W11111111111",
    "organization_id": "org_K11111111111",
    "workspace_id": "t_K11111111111",
    "initiator": "owner",
    "idempotency_key": "gateway-daily-report-01",
    "state": "ready",
    "failure_code": "",
    "failure_detail": "",
    "question": {
      "schema": 1,
      "shape": "overview",
      "scope": {
        "organization_id": "org_K11111111111",
        "workspace_id": "t_K11111111111",
        "application_ids": [
          "app_K11111111111"
        ],
        "gateway_ids": [
          "gw_K11111111111"
        ],
        "environment_ids": [
          "env_K11111111111",
          "env_M11111111111"
        ]
      },
      "from": "2026-09-20T12:00:00Z",
      "to": "2026-09-21T12:00:00Z",
      "display_tz": "UTC",
      "purposes": [
        "application"
      ],
      "metrics": [
        "requests.v1",
        "provider_spend.v1",
        "cost_per_1000_requests.v1"
      ]
    },
    "view_revision": "1",
    "window": {
      "from": "2026-09-20T12:00:00Z",
      "to": "2026-09-21T12:00:00Z"
    },
    "display_tz": "UTC",
    "authority_epoch": 14,
    "attempt": 1,
    "coverage": [],
    "truncated": false,
    "artifact_digest": "sha256:ceccd02ca24b008b84416282a088f37133e7d8646e830c54de3d84a2f01284d0",
    "group_count": 1,
    "byte_count": 1156,
    "created_at": "2026-09-21T12:00:00Z",
    "started_at": "2026-09-21T12:00:00Z",
    "finished_at": "2026-09-21T12:00:00Z",
    "expires_at": "2026-10-21T12:00:00Z",
    "answer": {
      "read_context": {
        "schema": 1,
        "central_account_id": "acct_K11111111111",
        "workspace_id": "t_K11111111111",
        "scope_digest": "f644d618df3314b70c1de5a5cc3d3da82b31dc31af3917ac8af4ab59279e2800",
        "authority_epoch": "1",
        "catalog_version": 1,
        "filter_version": 1,
        "projection_generation": "4",
        "retention_generation": "1",
        "exclusion_generation": "7",
        "from": "2026-09-20T12:00:00Z",
        "to": "2026-09-21T12:00:00Z",
        "display_tz": "UTC",
        "purposes": [
          "application"
        ],
        "arrival_fence": "918273",
        "watermarks": [
          {
            "producer_class": "gateway_journal",
            "produced_through": "918290",
            "indexed_through": "918273",
            "last_indexed_at": "2026-09-21T11:58:30Z"
          },
          {
            "producer_class": "control_plane_outbox",
            "produced_through": "41902",
            "indexed_through": "41902",
            "last_indexed_at": "2026-09-21T11:59:48Z"
          }
        ],
        "evaluated_at": "2026-09-21T12:00:00Z"
      },
      "minted": true,
      "shape": "overview",
      "groups": [
        {
          "purpose": "application",
          "cells": [
            {
              "metric": "requests.v1",
              "value": {
                "state": "available",
                "number": "1284",
                "unit": "count"
              }
            },
            {
              "metric": "provider_spend.v1",
              "value": {
                "state": "unavailable",
                "reason": "scope_not_authorized"
              }
            },
            {
              "metric": "cost_per_1000_requests.v1",
              "value": {
                "state": "unavailable",
                "reason": "scope_not_authorized"
              }
            }
          ]
        }
      ]
    },
    "presentation_digest": "sha256:ca85fdd9098d6278a96b6be763de0c98a669e50999083d02e20fe3570d64c3d1",
    "money_withheld": true
  }
}
```

Replayed from `report-show-json`, recorded for a viewer without `cost.read`. No role in this
release grants `metadata.read` without `cost.read`, so the recording applied the service's own
money redaction through a test wrapper.

Three things in that document tell you what you are holding. `window` is the exact UTC window
the run measured, and `display_tz` only labels it. `coverage` lists anything the reader could
not see; an empty list means the window was complete. And this document was opened without
`cost.read`, so `money_withheld` is `true`, both money cells read `unavailable` with the reason
`scope_not_authorized` and no figure, and `presentation_digest` differs from `artifact_digest`:
it hashes what you received, not the stored answer. With `cost.read`, `money_withheld` is
`false` and the two digests are equal.

Open it again tomorrow and you'll see the same window and the same `artifact_digest`.

## 5. Run it again

"Run again" is a new run with a new window. It does not refresh the first one. Run it with
`tokentriage cloud report run --org … --report rpt_… --idempotency-key gateway-daily-report-02`,
then poll the new run as in step 3:

```console
$ tokentriage cloud report run --org org_K11111111111 --report rpt_W11111111111 \
    --idempotency-key gateway-daily-report-02 --base-url http://127.0.0.1:13240
cloud: customer.reports.runs.create POST http://127.0.0.1:13240/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs (Idempotency-Key gateway-daily-report-02)
rrun_X11111111111	queued	[2026-09-21T12:00:00Z, 2026-09-22T12:00:00Z)	groups 0	
```

Replayed from `report-run-again`, recorded with the service clock moved forward 24 hours.

```console
$ tokentriage cloud report show --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_X11111111111 --base-url http://127.0.0.1:13250
cloud: customer.reports.runs.get GET http://127.0.0.1:13250/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_X11111111111
rrun_X11111111111	partial	[2026-09-21T12:00:00Z, 2026-09-22T12:00:00Z)	groups 1	sha256:4d600cc9385d1fa5010d6ed038d164baf20e5ed089d8b51f0cb0f07a8fc0e9d1
coverage  ingest_gap:gateway_journal
```

Replayed from `report-show-second-run`; the test reader answered that day with a journal gap and
part of the traffic unpriced.

The new run has its own `rrun_…` ID, window and digest. This one settled `partial`, and stderr
names the reason: the journal had a gap that day. `cloud report show` on the first run still
answers what it answered in step 4.

<Details summary="If run answers HTTP 412 precondition_failed">

The view has been edited since the report was created, and the report pins the revision it was
created at. A report never answers a question it was not created with. Create a new report from
the view's current revision, as in steps 1 and 2.

```console
$ tokentriage cloud report run --org org_K11111111111 --report rpt_W11111111111 \
    --idempotency-key gateway-daily-report-04 --base-url http://127.0.0.1:3366
cloud: customer.reports.runs.create POST http://127.0.0.1:3366/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs (Idempotency-Key gateway-daily-report-04)
Error: customer.reports.runs.create was refused as a stale revision (HTTP 412); detail: foundation: stale revision: saved view sview_K11111111111 is at revision 2 and this report pins 1; type=precondition_failed; request_id=req_report-run-stale-revision
```

Replayed from `report-run-stale-revision`, recorded after the view was edited to revision 2.
`report run` takes no `--expected-revision`, so the CLI prints the server's detail as it came;
the remedy is a new report.

</Details>

## 6. Export a run

Download the first run as CSV with
`tokentriage cloud report export --org … --report rpt_… --run rrun_… --format csv
--output report.csv`. The file is written verbatim, and stderr states its content type and a
`sha256` of the bytes that arrived:

```console
$ tokentriage cloud report export --org org_K11111111111 --report rpt_W11111111111 \
    --run rrun_W11111111111 --format csv --output report.csv \
    --base-url http://127.0.0.1:13256
cloud: customer.reports.runs.export GET http://127.0.0.1:13256/portal/api/v1/customer/v1/organizations/org_K11111111111/reports/rpt_W11111111111/runs/rrun_W11111111111/export?format=csv
cloud: exported 465 bytes (text/csv; charset=utf-8) to report.csv digest sha256:6872a0494f05cbda406f6f83715098d6e336ed8cede74c12c8a5ea8ec5c937c0
report.csv
```

Replayed from `report-export-csv`, exported by the owner, who holds `cost.read`, so the file's
spend cells carry nano-USD figures.

That digest hashes the file you downloaded. It is not the run's `artifact_digest`, which hashes
the stored answer. `--format json` writes the same run document `show --json` printed.

## 7. Do the same from a program

Both SDKs ship steps 1 to 4 as one example: read the view's revision, create the report with an
idempotency key, poll until the run settles (at most 60 polls, honouring `Retry-After`), and
print the run's window, coverage and artifact digest. This is the function, quoted from
`sdk/python/examples/create_gateway_report.py` and `sdk/ts/examples/create_gateway_report.ts`:

<CodeTabs>
  <Fragment slot="python">
  ```python
  def create_gateway_report(
      client: CustomerClient,
      org: str,
      ws: str,
      view: str,
      *,
      idempotency_key: str,
      retry_after: Callable[[], Optional[float]] = lambda: None,
      sleep: Callable[[float], None] = time.sleep,
  ) -> Mapping[str, Any]:
      # The revision is a uint64 carried as a STRING; it is copied, never parsed.
      revision = client.saved_views.get(org, view)["data"]["revision"]
      created = client.reports.create(
          org,
          ws,
          {
              "name": "Gateway daily report",
              "saved_view_id": view,
              "expected_view_revision": revision,
              "recipient_subject_ids": [],
              "evidence_refs": [],
          },
          idempotency_key=idempotency_key,
      )["data"]
      report_id, run_id = created["report"]["report_id"], created["run"]["run_id"]
      for _ in range(MAX_POLLS):
          run = client.reports.runs.get(org, report_id, run_id)["data"]
          if run["state"] in SETTLED:
              break
          delay = retry_after()
          sleep(DEFAULT_DELAY if delay is None else delay)
      else:
          raise TimeoutError(f"report run {run_id} is still {run['state']}; reopen it later")
      if run["state"] not in ("ready", "partial"):
          raise RuntimeError(f"report run {run_id} is {run['state']}: {run.get('failure_code')}")
      return {
          "report_id": report_id,
          "run_id": run_id,
          "state": run["state"],
          "coverage": run.get("coverage", []),
          "window": run["window"],
          "artifact_digest": run["artifact_digest"],
      }
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```typescript
  export async function createGatewayReport(
    client: CustomerClient,
    org: string,
    ws: string,
    view: string,
    options: GatewayReportOptions,
  ) {
    const sleep = options.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
    const { data: saved } = await client.savedViews.get(org, view);
    const { data: created } = await client.reports.create(
      org,
      ws,
      {
        name: "Gateway daily report",
        saved_view_id: view,
        // A uint64 carried as a STRING: copied, never parsed.
        expected_view_revision: saved?.revision ?? "",
        recipient_subject_ids: [],
        evidence_refs: [],
      },
      { idempotencyKey: options.idempotencyKey },
    );
    const reportId = created?.report?.report_id ?? "";
    const runId = created?.run?.run_id ?? "";
    for (let poll = 0; poll < MAX_POLLS; poll++) {
      const { data: run } = await client.reports.runs.get(org, reportId, runId);
      const state = run?.state ?? "queued";
      if ((SETTLED as readonly string[]).includes(state)) {
        if (state !== "ready" && state !== "partial") {
          throw new Error(`report run ${runId} is ${state}: ${run?.failure_code ?? ""}`);
        }
        return {
          report_id: reportId,
          run_id: runId,
          state,
          coverage: run?.coverage ?? [],
          window: run?.window,
          artifact_digest: run?.artifact_digest,
        };
      }
      await sleep(options.retryAfterMs?.() ?? DEFAULT_DELAY_MS);
    }
    throw new Error(`report run ${runId} is still running after ${MAX_POLLS} polls; reopen it later`);
  }
  ```
  </Fragment>
</CodeTabs>

Each script reads `TOKENTRIAGE_BASE_URL`, `TOKENTRIAGE_MANAGEMENT_KEY`, `TOKENTRIAGE_ORG`,
`TOKENTRIAGE_WORKSPACE` and `TOKENTRIAGE_SAVED_VIEW`, and prints one JSON line:

```console
$ python create_gateway_report.py
{"report_id": "rpt_W11111111111", "run_id": "rrun_W11111111111", "state": "ready", "coverage": [], "window": {"from": "2026-09-20T12:00:00Z", "to": "2026-09-21T12:00:00Z"}, "artifact_digest": "sha256:ceccd02ca24b008b84416282a088f37133e7d8646e830c54de3d84a2f01284d0"}
```

Run with Python 3.14 against a loopback server answering the four requests recorded in
`sdk-python-create-gateway-report`. The poll that found the run `queued` carried no
`Retry-After`, so the script waited its default two seconds.

```console
$ node create_gateway_report.mjs
{"report_id":"rpt_W11111111111","run_id":"rrun_W11111111111","state":"ready","coverage":[],"window":{"from":"2026-09-20T12:00:00Z","to":"2026-09-21T12:00:00Z"},"artifact_digest":"sha256:ceccd02ca24b008b84416282a088f37133e7d8646e830c54de3d84a2f01284d0"}
```

Bundled with esbuild and run with Node 24 against the four requests recorded in
`sdk-ts-create-gateway-report`.

A failed or cancelled run raises with its failure code instead of returning, and a run still
unsettled after 60 polls raises with its ID so you can reopen it later.

## What a report does not do yet

A report runs when you create it, when you run it again, or when a schedule runs it.
[Deliver a gateway report on a schedule](/docs/build/schedule-gateway-report) sets one up and
delivers each scheduled run to its recipients' in-app inboxes. A report raises no alerts. Listing a recipient lets them see the report and
grants them nothing else, so a recipient without access to the view's scope still cannot open
it. [Reporting and alerts](/docs/reference/reporting-and-alerts) lists every field, state,
problem code and limit.

<NextSteps items={[
  { title: "Reporting and alerts", href: "/docs/reference/reporting-and-alerts", description: "Every report operation, run state, problem code, limit and retention period in one table each." },
  { title: "What a report run keeps, and who can open it", href: "/docs/concepts/report-snapshots-and-access", description: "Why a run keeps its answer, and why a recipient may see fewer cells than you." },
  { title: "Save a gateway view and share it", href: "/docs/build/save-gateway-views", description: "Edit the question a report copies, and what an edit does to its revision." },
  { title: "Deliver a gateway report on a schedule", href: "/docs/build/schedule-gateway-report", description: "Run this report every morning and have each run land in a colleague's in-app inbox." }
]} />
