---
title: "Draw a chart in the conversation. Use it whenever the answer is a shape rather than a number: a trend over time, a compa"
method: POST
path: "/api/v1/tools/render_chart"
tags: ["read"]
---

# Draw a chart in the conversation. Use it whenever the answer is a shape rather than a number: a trend over time, a compa

`POST /api/v1/tools/render_chart`

## Request

- Base URL: `https://app.magistry.io`
- URL: `https://app.magistry.io/api/v1/tools/render_chart`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://app.magistry.io/api/oauth/authorize`, token URL `https://app.magistry.io/api/oauth/token`, scopes: `read`)

Draw a chart in the conversation. Use it whenever the answer is a shape rather than a number: a trend over time, a comparison across products or campaigns, a split of a total, a relationship between two measures. Prefer a chart to a markdown table for anything with more than about five rows of numbers, and always pass the figures you actually read from a tool — never invent or round data to make a nicer picture. Pick the kind by the question: line/area for a value over time; bar for magnitude across categories; stacked_bar or stacked_area when the TOTAL is also a real quantity; pie or donut for one whole split into a few parts; scatter for two measures against each other; combo for one measure as bars with another as a line. There is no dual-axis chart on purpose — two measures of very different scale go in two charts. ALWAYS set `unit` (and `currency` when it is money): a currency drawn as a bare number is a figure the merchant will read as the wrong thing. ALWAYS set `source` with the tool you read, the window, and the row count, so the merchant can trace the number back. At most 6 series; if you have more, chart the ones that matter and say so. Write a one-line answer in prose as well — the chart supports the sentence, it does not replace it.

## Request body

- object
  - `kind` 'line' | 'bar' | 'area' | 'stacked_bar' | 'stacked_area' | 'pie' | 'donut' | 'scatter' | 'combo', required: Chosen by the question the data answers, not by preference.
  - `data` object[], required: The rows, each an object. Every yKey must be a NUMBER in every row it appears in; use null for a genuinely missing point rather than 0, which is a measurement.
  - `xKey` string, required: The field on the x axis (or the slice name for pie/donut).
  - `yKeys` string[], required: The measures to plot. Six at most — the series palette has six slots and is never reused.
  - `labels` object: Display names keyed by raw field name, e.g. {"rev":"Revenue","d":"Day"}.
  - `title` string: What the chart shows, as a short noun phrase.
  - `subtitle` string: The window, the filter, or the caveat. One line.
  - `unit` 'number' | 'currency' | 'percent' | 'duration_s' | 'compact': How to format values. Percentages are whole numbers (12.5 means 12.5%), not fractions.
  - `currency` string: ISO 4217, when unit is currency. e.g. EUR, GBP, USD.
  - `barKeys` string[]: combo only: which series are bars.
  - `lineKeys` string[]: combo only: which series are the line.
  - `reference` object: An optional marked rule: a target, a break-even, last period's average.
    - `y` number, required
    - `label` string
  - `source` object: Where these numbers came from. Set it whenever the data came from a tool — it is what lets the merchant trace a figure back to the question that produced it.
    - `tool` string
    - `window` string
    - `rows` integer

## Response `200`

Tool result

- object
  - `ok` boolean, required
  - `result` unknown, required

## Other responses

- `400`: `invalid_request` — The request is malformed or an argument is unusable. The message names which. `invalid_body` — The body is not a JSON object. `invalid_expiry` — An approval request must end: `expires_at` lies at least 5 minutes and at most 7 days after it is filed. A request with no decision by then closes as expired, which means not approved. `invalid_rule` — The rule could not bind as written: a fact or action outside the vocabulary, an operator that cannot compare that fact, or a limit with no bound. Nothing was saved.
- `401`: `unauthorized` — No credential was presented, or the one presented is not valid. This deliberately does not say whether it was unknown or forged: a 401 that tells those apart is an oracle for guessing tokens. A credential that VERIFIED and is refused answers with its own code (expired, grant_revoked, app_disabled, address_not_allowed). `expired` — The credential verified and its expiry has passed. `grant_revoked` — The OAuth token verified, and the merchant revoked this connection after it was issued. `app_disabled` — The OAuth token verified, and Magistry has disabled this application for every merchant. No token of the application is admitted and none is issued. `address_not_allowed` — The credential verified and carries an IP allowlist that this request did not come from. `credential_rejected` — Diagnose could not resolve the credential at all. `no_credential` — Diagnose was called with no Authorization header, so there is nothing to report on. `invalid_client` — The client authentication on this request did not verify.
- `403`: `forbidden` — The credential is valid and does not hold a scope this tool requires. `insufficient_scope` — As `forbidden`, and the body names `required_scopes`. A webhook subscription needs both `automation:write` to configure the store and the read scope for every type it filters — a webhook is a read. `entity_not_granted` — The credential holds a finance scope, and the entity's owner has not given this app those books (or has taken them back). A finance scope says what an app may do; the owner's grant says which entities. A natural person's books need the person's own explicit delegate grant.
- `404`: `not_found` — No tool of that name is exposed to this credential. Note that a tool hidden by a missing scope answers `forbidden`, not this — the two are told apart deliberately.
- `409`: `idempotency_conflict` — This idempotency key was already used with DIFFERENT arguments. Replaying it would be a second, different write under a key that promises one. `idempotency_in_flight` — A request with this key is still running. This is not an error — it is "ask again", and the response carries `Retry-After`. `no_policy` — The app has no grant for this field on this store. Owning a field is a separate, larger grant than a scope: the reconciler pushes what the owner declares, and nobody else may touch the field meanwhile. `held_by_other` — Another owner holds this field, named in `held_by`. A field has at most one owner, enforced by a unique index, and an app never takes a field over from a live holder. `raced` — Another owner took this field in the same instant as this declaration. The database let exactly one of the two through. `merchant_kept` — The merchant kept an outside change to this field. Once kept, the database refuses every new declaration on it unless a store rule allows declaring it again. `too_many_pending` — This app already has the maximum number of approval requests pending on this store (the body carries `pending` and `limit`). The bound keeps one app from burying the merchant's screen. `already_closed` — The approval request was decided, expired or withdrawn before this call; `status` says which. A closed request never reopens. `store_unavailable` — The store is not accepting approval requests because Magistry is not installed on it. `too_many_rules` — The store already holds the maximum number of rules. `in_flight` — The declaration is being written to the platform at this moment, so it cannot be reverted underneath that write. `overwritten_externally` — Someone other than this app changed the field on the platform after the app's value was applied, and the merchant has not yet decided whether that change stays. A revert now would overwrite it, so nothing was reverted. `finance_proposal_refused` — The books refused the proposal in their current state: the entity's owner has not switched app proposals on (Books → Assistant), or the period it would land in is closed. Nothing was recorded and nothing was posted. `reason` names which. `export_not_ready` — The export job has not produced its file (yet): `status` in the body says whether it is still `queued`/`running` or `failed`, with the failure's own code.
- `410`: `finance_cursor_gone` — The change-feed cursor is older than the feed keeps (guarantees.finance_change_feed_retention_days in /api/v1/changelog.json) and events after it have been removed. The body carries `oldest_cursor`. `export_expired` — The export's file was downloadable until `expires_at` and is no longer kept.
- `413`: `payload_too_large` — The body exceeds the endpoint’s size bound.
- `429`: `rate_limited` — This credential exceeded its per-minute limit. The body carries the limit and the count, and the response carries `Retry-After` and the `RateLimit-*` headers.
- `500`: `simulation_unverified` — The simulation could not prove that its transaction was aborted, so no verdict is given: an answer from a run that may have kept something would read as a clean one. Reported on our side. `server_error` — Something on our side failed. The request may or may not have taken effect.
- `502`: `sandbox_failed` — Creating or seeding the sandbox failed. Provisioning is one transaction, so nothing was created and the key shown to nobody was never stored.
- `503`: `idempotency_unavailable` — The idempotency claim could not be taken, so the write was NOT attempted. This fails closed on purpose: running an unguarded write because the guard was unreadable is how a customer gets refunded twice. `approvals_unavailable` — Approval requests are not enabled on this server yet. Nothing was written. `receipts_unavailable` — Receipts are not enabled on this server yet. Nothing was exported. `policies_unavailable` — Store rules are not enabled on this server yet. Nothing was read or written. `sandbox_unavailable` — Developer sandboxes are not open: the database fence that keeps a sandbox key on demo data is not in place, or the provisioning worker is not configured. Nothing was created. `unavailable` — A read could not be served just now. Nothing was written or exported. `overwrites_unreadable` — We could not confirm that nobody else has changed this field since it was applied, and a revert that cannot check could overwrite a merchant's change. Nothing was reverted. `rls_scoped_execution_unavailable` — This deployment is not currently running the row-level-scoped execution mode the headless surface requires, so it refuses rather than executing with wider reach than the grant. `finance_grants_unavailable` — The entities this app may read could not be established for this request, so nothing was read — an unreadable grant is never treated as a wider one, nor as "no entities". `finance_unavailable` — The books could not be read or a figure could not be computed from them just now, so nothing was returned — never a partial list or a zero that would read as the ledger. `export_unavailable` — The export job could not be started, read or served just now — including a stored file whose bytes do not match the SHA-256 the job recorded, which is never sent. Nothing was started or sent. `corpus_unavailable` — The corpus this endpoint reads could not be loaded.

---

[API](https://skmtc.dev/magistry/apis/magistry-api.md) · [All operations](https://skmtc.dev/magistry/apis/magistry-api/llms.txt) · [OpenAPI document](https://skmtc.dev/magistry/apis/magistry-api/revisions/13dae20d49cb?raw)
