dataspring-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,712 @@
1
+ ---
2
+ name: dataspring-consume
3
+ description: How to query DataSpring correctly to answer analytical questions. Use when the user is asking analytical/business questions, when an agent is connected to a DataSpring MCP server, when running `dataspring query ...` from the terminal, or when scripting against DataSpring's REST API. Covers the three surfaces (MCP, CLI, REST) — same principles, different invocations.
4
+ ---
5
+
6
+ # Querying DataSpring to answer analytical questions
7
+
8
+ You are querying DataSpring. Three surfaces are available — pick the
9
+ one the caller is using:
10
+
11
+ - **MCP** — agent connected to a DataSpring MCP server (Claude
12
+ Desktop, Claude Code via stdio, etc.). Tool calls.
13
+ - **CLI** — `dataspring` from the terminal. Shell commands.
14
+ - **REST** — direct HTTP. Scripts, integrations.
15
+
16
+ The principles below are surface-agnostic, drawn from real production
17
+ traces — departing from them produces misleading numbers.
18
+
19
+ ## Surface cheatsheet
20
+
21
+ | Operation | MCP tool | CLI | REST |
22
+ |---|---|---|---|
23
+ | List metrics | `list_metrics()` | `dataspring metrics list` | `GET /api/semantic-layer/metrics` |
24
+ | List dimensions | `list_dimensions()` | `dataspring dimensions list` | `GET /api/semantic-layer/dimensions` |
25
+ | Explain one metric | `explain_metric(metric_name)` | `dataspring metrics show <name>` | `GET /api/semantic-layer/metrics/<name>` |
26
+ | Query | `query_metrics(metrics, dimensions?, grain?, start_date?, end_date?, where?, ...)` | `dataspring query -m <name> [-d ...] [-g month] ...` | `POST /api/dispatch/query_metrics` with the same fields |
27
+ | Explain what was computed | `explain_query(metrics, ...)` (same fields as `query_metrics`) | (no CLI command yet) | `POST /api/dispatch/explain_query` |
28
+ | What DataSpring learned / undo | `learned_edit list` / `undo` | `dataspring learned list` | `GET /api/learned` |
29
+ | Verified queries | `verified_query_edit list` / `record` | (no CLI command yet) | (no dedicated route) |
30
+ | Read business_context | `dataspring://context` resource | `dataspring business-context get` | `GET /api/context` |
31
+ | Check business_context size | `business_context_edit show_size` | `dataspring business-context size` | (no dedicated route — use `GET /api/context` and measure) |
32
+ | Org's published skill | (this skill, served via SKILL.md) | `dataspring skill instructions consume` | `GET /api/v1/skill/consume/instructions` |
33
+
34
+ No catalog is pre-loaded. You are handed NO catalog up front — the catalog
35
+ index (`list_metrics` / `dataspring metrics list` / `GET
36
+ /api/semantic-layer/metrics`) is the only place it exists, so start there
37
+ every time (see §2). Pass the user's question verbatim where the surface
38
+ accepts one — it is what lets the index flag an `ambiguous` set of
39
+ candidates (§2.1) instead of leaving you to guess between them.
40
+
41
+ All three surfaces hit the same backend, semantic layer, and authorisation
42
+ rules — same `business_context` and `description` bytes everywhere.
43
+
44
+ ## Getting the CLI and this skill
45
+
46
+ One line installs the CLI (it installs `uv` first if the machine has none),
47
+ then log in and install the skills the server serves, so the text an agent
48
+ reads is the one this deployment was built with:
49
+
50
+ ```bash
51
+ curl -fsSL https://dataspring.app/install.sh | sh # Windows: irm https://dataspring.app/install.ps1 | iex
52
+ dataspring login
53
+ dataspring skills install --all # into ~/.claude/skills/<name>/, stamped
54
+ ```
55
+
56
+ Run `dataspring skills install` again after `dataspring upgrade` (the CLI
57
+ upgrades itself in place; `dataspring --version` says when the server has
58
+ operations this CLI does not know). `dataspring skills diff` shows what
59
+ drifted between the installed copy and the served one; `--offline` on any
60
+ `skills` command uses the copy bundled in the CLI instead of the server.
61
+
62
+ ## 1. Knowledge layers — consult before querying
63
+
64
+ Before answering any analytical question, gather context from these
65
+ sources, in order:
66
+
67
+ | Layer | Source | What it gives you |
68
+ |---|---|---|
69
+ | 1. Org orientation | `business_context` (§13) | What the org sells, what's synced (and what isn't), cross-cutting derivation rules |
70
+ | 2. Semantic layer catalog | `list_metrics()` / `dataspring metrics list` / `GET /api/semantic-layer/metrics`, and the dimensions equivalents | An index of every metric and dimension in the org — names, grain, one-line summaries, most-used first |
71
+ | 3. Metric/dimension detail | `explain_metric(name)` / `dataspring metrics show <name>` / `GET /api/semantic-layer/metrics/<name>` | Unit, source rollup, standing filter rules, caveats (§3–§6) |
72
+ | 4. Runtime state | the query response itself | Whether the assumption actually holds — `NO_DATA`, an error `code`, a row count of zero |
73
+
74
+ Gather each layer ONCE. If you already have a layer, move to the next one; re-reading something
75
+ you have been given is the most common way to burn a turn without
76
+ improving the answer. And this ladder is for ANALYTICAL questions: a
77
+ greeting or a one-word reply needs none of it.
78
+
79
+ Each layer is cheaper to check than the next is to discover the hard way:
80
+ `business_context` says what's plausible, the catalog says what's
81
+ queryable, and only the query result can contradict the first three. Don't
82
+ skip straight to a query on a hunch — "obvious" metric names and assumed
83
+ data coverage are wrong often enough to matter.
84
+
85
+ ## 2. The discovery pattern
86
+
87
+ **No catalog is pre-loaded.** Whatever surface you are on, you have
88
+ not been given the org's metrics — not in your system prompt, not in
89
+ this skill, not in an attachment. The catalog exists only behind the
90
+ tools below, and it is only ever as current as the call you make.
91
+ Answering from a metric name you inferred, remembered, or assumed
92
+ because it is the obvious English word is the failure this section
93
+ exists to prevent.
94
+
95
+ For any analytical question, in this order:
96
+
97
+ 1. **List the catalog INDEX** — `list_metrics()` plus `list_dimensions()` on MCP; `dataspring metrics
98
+ list` on the CLI. You get names, type, grain and a one-line summary
99
+ per entry, ranked with the org's most-used entries first. It is an
100
+ index: enough to decide what is relevant, deliberately not enough to
101
+ answer with. Pass the user's question verbatim where the surface accepts one (§2.1), and
102
+ narrow a large index with the `query` substring parameter rather than
103
+ paging through it.
104
+ 2. **Explain what you picked** — `explain_metric` /
105
+ `explain_metric(names=[...])`, for every entity the question
106
+ touches, in ONE call. This is where the authoritative description
107
+ lives: unit, source rollup, standing filter rules, caveats (§3). Read
108
+ it before you query; do not paraphrase from the name or the summary.
109
+ 3. **Query** — only now, with the names and grains you just confirmed.
110
+
111
+ Don't skip step 1: "obvious" metrics like `total_revenue` differ across
112
+ orgs in unit, source, and standing filter rules, and a name matching the
113
+ question's English words is exactly the case where skipping feels safest
114
+ and is not.
115
+
116
+ Once you've read an entity's index row and description this conversation,
117
+ they stay in context — a follow-up or drilldown on the SAME entity goes
118
+ straight to the query. Re-list when the user pivots to a new subject.
119
+
120
+ **A `caveat` on an index row is not optional to skip.** The row
121
+ carries the metric's own KNOWN ISSUE / CAVEAT sentence (for example
122
+ `caveat: "KNOWN ISSUE: only surveyed tickets are counted"`). When a
123
+ row carries a `caveat`, step 1 alone is not enough — you MUST
124
+ call `explain_metric` (`explain_metric`) for that entity before
125
+ answering, and your answer must surface the caveat alongside the
126
+ number. A one-line summary is where a caveat goes silently missing
127
+ (§3).
128
+
129
+ This skill deliberately names no metrics of its own. Any metric or
130
+ dimension list in front of you comes from your org's semantic
131
+ layer, never from this document — a catalog written into a skill
132
+ drifts from the manifest the moment someone edits a dbt model.
133
+
134
+ ## 2.1 Disambiguate instead of guessing
135
+
136
+ When two or more metrics plausibly answer the question, **ask — do not
137
+ pick**. Picking one and reporting its number is the most expensive
138
+ mistake available to you: the answer looks finished, so nobody checks it.
139
+
140
+ Calibrate on a real case: one org's revenue metrics exist as explicit
141
+ `*_ex_vat` / `*_inc_vat` pairs because the two readings differ by 25% and an
142
+ agent that silently chose was wrong a quarter of the time.
143
+
144
+ **What counts as ambiguous.** Several metrics match what the question
145
+ named, and nothing in the question separates them. A period, a filter or a
146
+ breakdown does NOT separate them ("churn last month" is exactly as
147
+ ambiguous as "churn" — a window narrows the query, never the metric).
148
+ Endorsement does not either — a metric on ten dashboards is the org's
149
+ *default*, not an answer to "which reading did you mean"; lead your
150
+ question with it, still ask. What DOES separate them is a term the user
151
+ supplied that fits one and not the others ("churn *rate*", "*total*
152
+ revenue"), or a description that answers the question the others cannot —
153
+ one clear winner: use it, and say which you used.
154
+
155
+ **Check before you query, not after.** A bare family term with no
156
+ qualifier of its own — treat it as ambiguous until the catalog says
157
+ otherwise; the ambiguity is only visible once you can see the whole family
158
+ side by side.
159
+
160
+ `list_metrics` runs this check for you when you pass the user's
161
+ question verbatim. When it cannot separate the candidates it returns an
162
+ `ambiguous` block naming them: stop and ask — **do not query any of them**
163
+ without a reason you could state out loud. The check is a floor, not a
164
+ verdict either way: no flag is not a clearance when you can see two
165
+ readings it did not catch (ask anyway); a flag is not always the last
166
+ word if the user's own wording already picks one out (use that one and
167
+ say so).
168
+
169
+ **Querying every candidate is not disambiguation.** Running all eight
170
+ members of a family and reporting the set answers a question the user
171
+ did not ask, costs eight queries, and leaves them to pick the number
172
+ themselves — which was your job to ask about.
173
+
174
+ **How to ask.** One short question that (1) names at least two candidates
175
+ by their exact catalog identifiers — "Net or gross?" without names leaves
176
+ the user guessing which metrics you mean — and (2) says what distinguishes
177
+ them (unit, source, population, period, standing filter; the index
178
+ summaries are usually enough, `explain_metric`/`explain_metric` if
179
+ you need more). Then stop and wait — do not query, and do not answer
180
+ "either way, here is one of them". Example, using the VAT pair above:
181
+
182
+ > "`total_event_revenue_ex_vat` or `total_event_revenue_inc_vat`? The
183
+ > first is net; the second includes 25% VAT."
184
+
185
+ A list of every candidate is not a question; two or three, named, with the
186
+ difference stated, is — and the names must come from the catalog you just
187
+ read, never from this document.
188
+
189
+ This is a different move from declaring a gap (§8): a gap means the
190
+ semantic layer *cannot* answer, ambiguity means it can answer in several
191
+ ways and only the user knows which.
192
+
193
+ ## 3. Read the description fields
194
+
195
+ The `description` on each metric and dimension is **authoritative** — the
196
+ source of truth for unit, computation, source rollup, filter rules, and
197
+ caveats. Read it; do not paraphrase or infer the unit from the name.
198
+
199
+ What it tells you:
200
+
201
+ - **Unit** — DKK, count, %, hours. State it back explicitly in the answer.
202
+ - **Source rollup** — which systems contribute; say so if the user asks
203
+ about one system and the metric rolls up several.
204
+ - **Caveats** — temporal discontinuities, methodology changes, bug-fix
205
+ dates (see §6). A catalog row carrying a `caveat` sentence (§2) signals
206
+ this — quote it, don't answer from the index row alone.
207
+ - **Standing filter rules** — "for B2B, pair with…". Apply unless the
208
+ user explicitly overrides.
209
+ - **Derived-field pointers** — "use `event_segment`, not raw
210
+ `event_type`" (see §5).
211
+
212
+ Byte-identical across MCP, CLI, and REST — authored once in dbt YAML,
213
+ served everywhere.
214
+
215
+ ## 4. Apply standing filter rules from descriptions
216
+
217
+ If a metric's description says "pair with `status='paid'` to exclude
218
+ voided invoices", include that filter unless the user explicitly asks for
219
+ the unfiltered view — the author wrote it because querying without it is a
220
+ known footgun. **MCP** passes it through `query_metrics(filter=...)` where
221
+ supported (else mention it in the answer); **CLI** (`dataspring query` has no
222
+ ad-hoc filters yet) and **REST** (POST body) mention or include it the same
223
+ way. Don't argue or substitute your own interpretation — the description is
224
+ closer to the warehouse than you are.
225
+
226
+ ## 4.1 Echo the defaults that were applied for you
227
+
228
+ The user's saved preferences are applied to the query before it runs — a
229
+ standing filter, a default segment, a metric substitution. The response
230
+ reports them as `applied_defaults`, each with an `echo_text` phrase
231
+ (MCP: in the `query_metrics` response dict; CLI: printed with the
232
+ result; REST: in the `POST /query` body). Say every one in the answer, beside the
233
+ number it changed:
234
+
235
+ > Reseller revenue: $94k (excluding direct billings — your default)
236
+
237
+ A defaulted number is not the unqualified number, and the reader cannot
238
+ see the query. An explicit request in this turn wins over a standing
239
+ default and nothing is reported for it — say that too ("including direct
240
+ billings this time, overriding your standing filter"). Omitting the echo
241
+ is how a filtered figure gets quoted in a board deck as the total.
242
+
243
+ ## 5. Prefer derived dimensions when descriptions point that way
244
+
245
+ Descriptions often steer you to a derived dimension over a raw one
246
+ (`customer_segment` over `customer.tier`, `event_segment` over
247
+ `event_type`) — the derived version layers business logic the raw field
248
+ usually lacks or mismodels. Use the qualified name verbatim from the
249
+ dimensions catalog (e.g. `pos_order_line__venue_name`, not `venue_name`) —
250
+ the prefix names the owning semantic model.
251
+
252
+ ## 6. Honor temporal discontinuities
253
+
254
+ When a description warns "pre-X date is unreliable" or "ETL bug fixed Y
255
+ date", segment your query and tell the user about the discontinuity.
256
+ Example: "pre-2024-04-01 numbers exclude refunds; ETL bug fixed
257
+ 2024-04-15" plus a year-over-year ask across that boundary means running
258
+ two queries (before/after) and calling out the methodology change rather
259
+ than silently quoting one misleading trend. Same rule regardless of
260
+ surface — same warehouse, same boundaries.
261
+
262
+ ## 7. Insights, not just data
263
+
264
+ Don't just return raw query results. Interpret what the number
265
+ means in the org's business context.
266
+
267
+ | Bad | Good |
268
+ |-----|------|
269
+ | "Revenue: 2.1M DKK" | "Revenue hit 2.1M DKK in January, up 12% YoY. Lunch drove most of the growth while events dipped seasonally." |
270
+ | "NPS: 48" | "NPS at 48 (strong). Food scores highest (4.2/5) while atmosphere lags (3.8/5). Summer months consistently outperform winter." |
271
+
272
+ Always contextualize with all THREE of these. They are DIFFERENT
273
+ things, and a good answer that does two of them is still missing one:
274
+
275
+ 1. **Comparison** — the figure next to another figure: YoY, MoM, vs a
276
+ trailing average, vs another period or segment. A direction word
277
+ alone ("up", "strong") is not one; there must be something it is up
278
+ against.
279
+ 2. **Relative size** — the figure sized against a WHOLE: a share or
280
+ percent of a total, a rate, a per-unit figure, or a breakdown of the
281
+ parts. Note what this is NOT: "up 4.4% month over month" is a
282
+ comparison, not a relative size. A percentage CHANGE measures the
283
+ same number against its own past; sizing measures it against
284
+ something else — a total, a denominator, its own components.
285
+ 3. **Business implication** — a driver, a risk, a data-quality caveat
286
+ that blocks a decision, or a next step. Restating the trend in words
287
+ ("growth moderated") is NOT an implication; it has to say what the
288
+ number means for the reader.
289
+
290
+ **A question whose literal answer is one number is exactly the case
291
+ this rule is for, not an exception to it.** Shipping "Total revenue
292
+ last month was 3,525,734.18" is literally correct and still a failure:
293
+ you already have the query, one more window is one more call, and a
294
+ breakdown you already fetched costs nothing but arithmetic.
295
+
296
+ **But context is built from the metric you were asked about, not from
297
+ extra ones.** Widen the WINDOW of the metric the question names, or
298
+ break it down by a dimension it already has — do not go and fetch
299
+ neighbouring metrics to manufacture a story. Asked for one metric,
300
+ answer with THAT metric over two periods; do not also pull the two
301
+ related ones because they would make the paragraph richer. That reads as
302
+ not knowing which metric the question was about, it is the single
303
+ commonest way to turn a 3-call turn into a 6-call one, and the extra
304
+ metrics are exactly the near-misses you are supposed to avoid.
305
+
306
+ And when the figure is already in the conversation, the context is
307
+ too: answer from it. Do not spend a call re-reading your own previous
308
+ query to remember what you asked.
309
+
310
+ ## 8. Declare the gap when the semantic layer can't answer
311
+
312
+ If a question requires a metric, dimension, or join that doesn't exist in
313
+ the catalog, say so explicitly before doing anything else — don't run a
314
+ query against the closest-named metric and report a misleading number,
315
+ and don't improvise a raw-SQL query around the gap: query generation stops
316
+ at the semantic layer and never falls back to hand-written SQL.
317
+
318
+ State the gap in this form, verbatim:
319
+
320
+ ```
321
+ SEMANTIC LAYER GAP: [reason]
322
+ Falling back because: [specific missing metric/dimension/join]
323
+ ```
324
+
325
+ Then offer the checklist:
326
+
327
+ ```
328
+ Would you like me to add this to the semantic layer? I can propose:
329
+ - [ ] New metric: `metric_name`
330
+ - [ ] New dimension: `dimension_name`
331
+ - [ ] New semantic model for `entity_name`
332
+ ```
333
+
334
+ That checklist is for a gap you cannot compute around. When you CAN compute
335
+ it — every ingredient is in the catalog — the branch below replaces the
336
+ checklist: an admin is owed the definition itself, not a checkbox asking
337
+ whether to write one.
338
+
339
+ Valid reasons: the metric doesn't exist, a required dimension isn't
340
+ exposed, the question needs a join across entities no semantic model
341
+ connects, or it's ad-hoc exploration ahead of defining a metric.
342
+
343
+ Say it exactly this way rather than paraphrasing — the fixed shape is
344
+ what makes it easy to scan in a long conversation.
345
+
346
+ **When the ingredients exist and only the metric is missing.**
347
+ Sometimes every input is in the catalog and only the combination is
348
+ absent — revenue per ticket, where both `total_revenue` and
349
+ `tickets_created` exist and no ratio metric does. Being able to
350
+ compute it does not make it defined. Declare it anyway, in this
351
+ order:
352
+
353
+ 1. **Declare** — the verbatim block above, naming the missing metric.
354
+ 2. **Compute** — deliver the number as a labelled ephemeral
355
+ computation from rows you fetched (§8.1): say which metrics it was
356
+ computed from — **every** one of them, by name, not just the one the
357
+ unit happens to mention — and that it is not a defined metric. A rate
358
+ phrased as "34.4 per 1,000 usage events" names the denominator and
359
+ leaves the numerator unattributed: the reader cannot tell which metric
360
+ it was.
361
+ 3. **Offer the durable path** for this user's role — an admin gets a
362
+ concrete definition, reached by asking `metric_edit` for a `preview`
363
+ and quoting back the `dbt_yaml` it returns; a member or viewer gets a quick metric via
364
+ `quick_metric_edit`. For an admin this happens in the SAME turn and
365
+ without asking: a preview writes nothing, so it needs no permission, and
366
+ only `create` does. Offering to propose a definition is not proposing
367
+ one — the YAML has to be in the answer.
368
+
369
+ Steps 1 and 2 are identical for every role. Only step 3 forks, and the
370
+ fork has to be visible in the answer:
371
+
372
+ | Role | Bad | Good |
373
+ |---|---|---|
374
+ | admin / owner | Opens "## Revenue per Support Ticket", shows a ratio table, offers to save it as a quick metric — or to propose a definition without showing one. No declaration, no YAML; the org still has no metric afterwards. | Opens with the verbatim gap block; gives the ratio computed from `total_revenue` and `tickets_created`, labelled ephemeral; then quotes the dbt YAML the delegated preview returned, in full, and asks whether to create it. |
375
+ | member / viewer | The same ratio table, presented as a finished figure the org agreed on. | The same gap block and the same labelled ratio; then "I can save this as a personal quick metric — `qm:revenue_per_ticket` — queryable from now on." Nothing org-scoped is offered or attempted. |
376
+
377
+ An admin answered the member's way is the exact failure this fork exists
378
+ to end: the arithmetic is right and the semantic layer is unchanged.
379
+
380
+ Do not silently divide two numbers in prose. An unlabelled quotient
381
+ reads as a figure the org has agreed on, and it is not one — that
382
+ substitution, not the arithmetic, is what this branch exists to end.
383
+
384
+ ## 8.1 Compute, persist, or declare
385
+
386
+ A question that needs arithmetic over fetched rows — a growth rate, a
387
+ ratio, a share, two queries blended into one number — is answered by
388
+ fetching every ingredient with a metric query and computing from those rows
389
+ yourself (or in your host's own code tool, if it has one). DataSpring does
390
+ not run code for you.
391
+
392
+ ### Two rules worth knowing before your first query
393
+
394
+ **Categorical dimension values are stored LOWER-CASED.** Write
395
+ `= 'enterprise'`, not `= 'Enterprise'`. A wrong-cased literal matches
396
+ nothing and returns zero rows with no error — you will get a `hint` naming
397
+ the real values, but that costs a round trip you did not need to spend.
398
+ When you are unsure of a value, the hint on an empty result is
399
+ authoritative: use it rather than guessing again.
400
+
401
+ **`order_by` takes `-field` for descending, not `field desc`.** Both are
402
+ accepted now (the SQL suffix is translated for you), but write
403
+ `-total_revenue` — and leave `order_by` out entirely on a single-row
404
+ aggregate, where there is nothing to sort.
405
+
406
+ Which rung a number belongs on:
407
+
408
+ | The number is... | Path |
409
+ |---|---|
410
+ | One-off arithmetic over rows you fetched | compute it yourself: one metric query per ingredient (`query_metrics` / `dataspring query` / `POST /query`), then the arithmetic, then the attributed answer |
411
+ | A derived number THIS user will want repeatedly | `quick_metric_edit` — persisted, and queried afterwards with the `qm:` prefix |
412
+ | A durable metric the whole org should share | the admin path — `metric_edit` preview, then the `dbt_yaml` it returns |
413
+
414
+ Three rules hold on every rung:
415
+
416
+ - **A computed number is ephemeral and is never a defined metric.** It
417
+ is a number computed for this one answer; nothing was added to the
418
+ semantic layer, and nobody else can query it.
419
+ - **Fetch every ingredient.** Run a metric query for each one; never reuse
420
+ a figure from prose, a dashboard title or an earlier turn as a literal —
421
+ a hardcoded input can't be reproduced from what was actually queried.
422
+ - **Attribute every computed number in the answer, however it was computed.**
423
+ Name the metrics it came from — `total_revenue`, `tickets_created`, not a
424
+ prose label a table happened to use for them ("Total Sales", "New
425
+ Tickets") — and say what it is not — "computed from `total_revenue` and
426
+ `tickets_created`; not a defined metric".
427
+ - **Raw SQL never, on any path.** Rows come from a metric query. There is
428
+ no second way.
429
+
430
+ When the query response carries `applied_defaults` (or `notes`, or `hint`),
431
+ carry it into the answer beside the number (§4.1): a defaulted figure quoted
432
+ as the unqualified one is the mistake this section exists to prevent.
433
+
434
+ ## 9. When to update the semantic layer
435
+
436
+ Declaring a gap (§8) doesn't by itself mean the semantic layer
437
+ needs a change. After the gap is declared, judge whether the
438
+ question represents a reusable business need:
439
+
440
+ | Situation | Action |
441
+ |-----------|--------|
442
+ | One-off exploration | No update needed |
443
+ | Recurring metric need | Create a metric |
444
+ | Missing dimension | Add it to the relevant semantic model |
445
+ | New entity relationship | Consider a new semantic model |
446
+
447
+ Separate from *where a fact belongs once captured*: one metric/dimension/
448
+ entity fact goes in that object's `description` in the dbt YAML;
449
+ a cross-cutting organisation fact goes in `business_context`. This table
450
+ answers *whether it's worth capturing*; that rule answers *which document*.
451
+
452
+ Propose the change via `metric_edit` / `semantic_model_edit` (MCP), `dataspring metrics create` /
453
+ `dataspring models create` (CLI), or the equivalent admin REST routes —
454
+ admin/owner only. Show it first (§8): a `preview` compiles the definition
455
+ and writes nothing, so it needs no consent. What needs the user's yes is
456
+ `create`; never make that change unilaterally.
457
+
458
+ *How* to make that proposal — surface ownership, the member alternative,
459
+ when it needs a dbt YAML snippet instead of a write, and undo — is the
460
+ `dataspring-correct` skill (`GET /api/v1/skill/correct/instructions`); this
461
+ section only decides whether the change is worth making.
462
+
463
+ ## 10. Refuse hopeless queries honestly
464
+
465
+ If a question requires data the catalog doesn't expose, or the
466
+ `business_context` warns the relevant system isn't synced, say so.
467
+ Don't run a query against the closest-named metric and report a
468
+ misleading number.
469
+
470
+ Examples of honest refusals:
471
+
472
+ - "I can't answer how many emails went out — `business_context`
473
+ notes the org doesn't sync staff mailboxes."
474
+ - "I can't answer attribution by paid-search keyword — there's no
475
+ metric or dimension in the catalog that captures search keyword."
476
+
477
+ ## 11. Tool / response error recovery
478
+
479
+ Errors come back with a `code` and a `suggestion` (canonical name; older
480
+ paths still expose `hint` as an alias). Follow the suggestion.
481
+
482
+ Codes you'll see:
483
+
484
+ - `METRIC_NOT_FOUND` / `QUICK_METRIC_NOT_FOUND` — suggestion lists
485
+ available names. Don't retry with the same name; pick from the
486
+ suggestion or ask the user.
487
+ - `NO_DATA` — exists, but the date range / filter combination returned
488
+ nothing. Tell the user; don't fabricate a zero.
489
+ - `NO_BASE_METRICS` — quick metric expression has no dependencies to
490
+ resolve. Almost always a config bug; surface it.
491
+ - `EXCEEDS_INSTRUCTIONS_CAP` — `business_context_edit` write over the
492
+ 2000-byte UTF-8 cap. The error includes `current_bytes`/`cap_bytes`;
493
+ trim the content, don't loop.
494
+
495
+ A qualified-dimension complaint (e.g. `venue_name` rejected, expecting
496
+ `pos_order_line__venue_name`) — retry with the qualified name once, don't
497
+ loop on the same error.
498
+
499
+ **Recovery is YOUR business, never the reader's.** Once you have recovered,
500
+ answer as if the failed attempt never happened. The reader asked a
501
+ question about their business; they did not ask what your tool calls did.
502
+ Never open an answer with, or otherwise mention:
503
+
504
+ - "The validation error was caused by an invalid request payload…"
505
+ - "The query is now valid."
506
+ - "I retried with valid structured arguments."
507
+ - "No additional write was needed — it already contains…"
508
+
509
+ Each of those is a real answer this agent has shipped, and each one
510
+ replaced the sentence the reader actually needed. If you could not
511
+ recover, say what you could not find out and what you would need — that
512
+ is about THEM. If you did recover, just answer.
513
+
514
+ Surface shape: **MCP** returns `{error, code, suggestion}` in the tool
515
+ response (`hint` mirrors `suggestion` for back-compat); **CLI** prints
516
+ "✗ Error: …" with a hint line, exit code 1; **REST** is HTTP 4xx/5xx with
517
+ `{"detail": {"message", "code", "suggestion", ...}}`.
518
+
519
+ ## 12. Multi-turn / multi-call efficiency
520
+
521
+ After the catalog is loaded once:
522
+
523
+ - Drilldowns ("show me the same broken down by region") go
524
+ straight to the query call. No re-discovery needed.
525
+ - Comparison questions ("how does this compare to last year?")
526
+ are a second query call with adjusted dates.
527
+ - Refinements ("can you exclude internal accounts?") add a
528
+ filter; respect any standing rules from the metric description
529
+ on top of the user's ad-hoc filter.
530
+
531
+ **If the figure is already in this conversation, answer from it.** A
532
+ follow-up like "which of those days was weakest?" is arithmetic over rows
533
+ you already fetched and printed — re-running the same query to re-read
534
+ your own table costs a round trip and risks answering from a different
535
+ result than the one the user is looking at. Query again only when the
536
+ question needs data you have not got: a new metric, a new window, a new
537
+ breakdown.
538
+
539
+ The catalog is only stale if the user pivots to a brand-new
540
+ analytical area; in that case, re-list.
541
+
542
+ ## 13. The org's `business_context` document
543
+
544
+ Cross-cutting org orientation lives in `business_context` — what
545
+ the org sells, what's synced, what's NOT synced, cross-cutting
546
+ derivation rules. Hard cap: 2000 UTF-8 bytes.
547
+
548
+ How it reaches you:
549
+
550
+ - **CLI**: `dataspring business-context get` prints it.
551
+ `dataspring business-context size` shows the byte budget.
552
+ - **REST**: `GET /api/context` returns `{"content": "..."}`.
553
+
554
+ Treat `business_context` as part of the same authoritative layer
555
+ as metric descriptions. Same provenance (dbt repo via the
556
+ manifest workflow), same trust level.
557
+
558
+ That's the working agreement. Same principles, three surfaces —
559
+ do this and the answers will be right.
560
+
561
+ ## 14. Parameter reference
562
+
563
+ <!-- dispatch-params: query_metrics explain_query run_sql update_context quick_metric_edit learned_edit verified_query_edit business_context_edit -->
564
+ _Generated from the dispatch registry (`GET /api/dispatch`); do not edit by hand. Field descriptions are the models' own, verbatim on every surface._
565
+
566
+ #### `query_metrics`
567
+
568
+ MCP tool `query_metrics`; `POST /api/dispatch/query_metrics`. Fetch metric data from the warehouse.
569
+
570
+ | Parameter | Type | Required | Description |
571
+ |---|---|---|---|
572
+ | `metrics` | list of string | yes | List of metric names to query (e.g., ['total_revenue', 'order_count']) |
573
+ | `dimensions` | list of string | no | Dimensions to group by. Use qualified names from dataspring://dimensions (e.g., ['customer__segment', 'order__region']) |
574
+ | `grain` | string | no | Time granularity: 'day', 'week', 'month', 'quarter', or 'year' |
575
+ | `start_date` | string | no | Start date in YYYY-MM-DD format |
576
+ | `end_date` | string | no | End date in YYYY-MM-DD format |
577
+ | `limit` | integer | no | Maximum number of rows to return |
578
+ | `order_by` | string | no | Column to sort by, append ' desc' for descending (e.g., 'total_revenue desc') |
579
+ | `where` | list of string | no | Row filters in MetricFlow's constraint syntax, one string each, e.g. "{{ Dimension('customer__segment') }} NOT IN ('Direct')". Use qualified names from dataspring://dimensions - an unresolvable name comes back as an error whose suggestion names the qualified form. `where` decides WHICH ROWS the numbers cover; `dimensions` breaks the numbers OUT by a column; send both for a breakdown of a filtered slice. A `where` on a dimension also overrides the user's standing filter on that same dimension for this call. |
580
+ | `warehouse` | string | no | Which of the org's warehouses to run against - an id from dataspring://warehouses ('managed', 'external', ...). Omit for the active one. During a migration ask both and compare. |
581
+ | `suggest_visualization` | boolean | no, default `False` | Also return a suggested visualization type for the result. |
582
+ | `format` | one of `json`, `csv` | no, default `json` | 'json' (default) answers rows in `data`; 'csv' answers the same result as one CSV document in `content` (header row first) for a file or a spreadsheet, with `columns` and `row_count` alongside. |
583
+
584
+ #### `explain_query`
585
+
586
+ MCP tool `explain_query`; `POST /api/dispatch/explain_query`. Show what a query computes without running it: the resolved metrics and dimensions, the effective time window, the filters (including any standing preference folded in) and the compiled SQL. Use it when the user asks what you actually computed, disputes a number, or before proposing a correction. ``warehouse`` picks one of the org's warehouses; omit it for the active one.
587
+
588
+ | Parameter | Type | Required | Description |
589
+ |---|---|---|---|
590
+ | `metrics` | list of string | yes | List of metric names to query (e.g., ['total_revenue', 'order_count']) |
591
+ | `dimensions` | list of string | no | Dimensions to group by. Use qualified names from dataspring://dimensions (e.g., ['customer__segment', 'order__region']) |
592
+ | `grain` | string | no | Time granularity: 'day', 'week', 'month', 'quarter', or 'year' |
593
+ | `start_date` | string | no | Start date in YYYY-MM-DD format |
594
+ | `end_date` | string | no | End date in YYYY-MM-DD format |
595
+ | `limit` | integer | no | Maximum number of rows to return |
596
+ | `order_by` | string | no | Column to sort by, append ' desc' for descending (e.g., 'total_revenue desc') |
597
+ | `where` | list of string | no | Row filters in MetricFlow's constraint syntax, one string each, e.g. "{{ Dimension('customer__segment') }} NOT IN ('Direct')". Use qualified names from dataspring://dimensions - an unresolvable name comes back as an error whose suggestion names the qualified form. `where` decides WHICH ROWS the numbers cover; `dimensions` breaks the numbers OUT by a column; send both for a breakdown of a filtered slice. A `where` on a dimension also overrides the user's standing filter on that same dimension for this call. |
598
+ | `warehouse` | string | no | Which of the org's warehouses to run against - an id from dataspring://warehouses ('managed', 'external', ...). Omit for the active one. During a migration ask both and compare. |
599
+
600
+ #### `run_sql`
601
+
602
+ MCP tool `run_sql`; `POST /api/dispatch/run_sql`. Run ONE read-only SQL statement against the org's warehouse and get the rows back, ungoverned.
603
+
604
+ | Parameter | Type | Required | Description |
605
+ |---|---|---|---|
606
+ | `sql` | string | yes | One SQL statement (BigQuery Standard SQL on a managed warehouse). Name tables as <org>_marts.<table>, <org>_core.<table> or <org>_staging.<table>; the identity it runs as can read those and nothing else, so a write or a reach outside them is refused by the warehouse. |
607
+ | `warehouse` | string | no | One of the org's warehouses (see dataspring://warehouses); omit for the active one. |
608
+ | `max_rows` | integer | no, default `1000` | Rows to return at most (default 1000, cap 10000). Aggregate in SQL rather than paging. |
609
+ | `format` | one of `json`, `csv`, `markdown` | no, default `json` | 'json' answers rows in `data`; 'csv' or 'markdown' answer one document in `content`. |
610
+ | `dry_run` | boolean | no, default `False` | Only estimate the bytes the statement would process (and check it parses); run nothing. |
611
+
612
+ #### `update_context`
613
+
614
+ MCP tool `update_context`; `POST /api/dispatch/update_context`. Update user preferences (merge, not replace).
615
+
616
+ | Parameter | Type | Required | Description |
617
+ |---|---|---|---|
618
+ | `updates` | object | yes | Fields to update; only these change. Presentation: default_currency, default_grain, decimal_places, preferred_chart_type. Lists and maps REPLACE what is stored: favorite_metrics, standing_filters ([{dimension, operator: in\|not_in\|eq\|neq, values}]), metric_substitutions ({asked: preferred}), default_segment ({dimension, value} or null). |
619
+
620
+ #### `quick_metric_edit`
621
+
622
+ MCP tool `quick_metric_edit`; `POST /api/dispatch/quick_metric_edit`. Edit user-defined quick metrics (arithmetic expressions over existing metrics). The body is `{"action": {"action": "<verb>", ...}}`, one verb per table below.
623
+
624
+ **`create`** —
625
+
626
+ | Parameter | Type | Required | Description |
627
+ |---|---|---|---|
628
+ | `name` | string | yes | Metric name (e.g., "revenue_per_order") |
629
+ | `expression` | string | yes | Arithmetic expression over existing metrics (e.g., "total_revenue / order_count") |
630
+ | `description` | string | no | |
631
+
632
+ **`update`** —
633
+
634
+ | Parameter | Type | Required | Description |
635
+ |---|---|---|---|
636
+ | `metric_id` | string | yes | |
637
+ | `name` | string | no | |
638
+ | `expression` | string | no | |
639
+ | `description` | string | no | |
640
+
641
+ **`delete`** —
642
+
643
+ | Parameter | Type | Required | Description |
644
+ |---|---|---|---|
645
+ | `metric_id` | string | yes | |
646
+
647
+ #### `learned_edit`
648
+
649
+ MCP tool `learned_edit`; `POST /api/dispatch/learned_edit`. Browse and undo the learned trail — what DataSpring learned for this org. The body is `{"action": {"action": "<verb>", ...}}`, one verb per table below.
650
+
651
+ **`list`** — List what DataSpring learned for this org (plus your personal entries).
652
+
653
+ | Parameter | Type | Required | Description |
654
+ |---|---|---|---|
655
+ | `limit` | integer | no, default `20` | |
656
+ | `include_reverted` | boolean | no, default `False` | |
657
+
658
+ **`undo`** — Revert one learning (or, with no id, your most recent reversible one).
659
+
660
+ | Parameter | Type | Required | Description |
661
+ |---|---|---|---|
662
+ | `learning_id` | string | no | Learning to revert; omit for your most recent reversible entry |
663
+
664
+ #### `verified_query_edit`
665
+
666
+ MCP tool `verified_query_edit`; `POST /api/dispatch/verified_query_edit`. Curate verified queries — confirmed (question, query) pairs. The body is `{"action": {"action": "<verb>", ...}}`, one verb per table below.
667
+
668
+ **`list`** — The org's verified queries plus your own personal ones.
669
+
670
+ | Parameter | Type | Required | Description |
671
+ |---|---|---|---|
672
+ | `limit` | integer | no, default `20` | |
673
+
674
+ **`record`** — Record one confirmed (question, query) pair.
675
+
676
+ | Parameter | Type | Required | Description |
677
+ |---|---|---|---|
678
+ | `question` | string | yes | The user's question, verbatim |
679
+ | `params` | object | yes | The query that answers it, in the query_metrics wire shape: metrics, dimensions, grain, start_date, end_date, where, order_by, limit |
680
+ | `note` | string | no | One line on WHY this is the right query for that question |
681
+ | `scope` | one of `org`, `user` | no, default `user` | 'user' (the default) records it in this user's personal set; 'org' curates it for everyone and requires admin/owner |
682
+
683
+ **`delete`** — Remove one verified query by id.
684
+
685
+ | Parameter | Type | Required | Description |
686
+ |---|---|---|---|
687
+ | `id` | string | yes | |
688
+
689
+ #### `business_context_edit`
690
+
691
+ MCP tool `business_context_edit`; `POST /api/dispatch/business_context_edit`. Edit the org's cross-cutting business_context document. The body is `{"action": {"action": "<verb>", ...}}`, one verb per table below.
692
+
693
+ **`get`** — Read the current org business_context document.
694
+
695
+ No parameters.
696
+
697
+ **`show_size`** — Report current size and remaining room within the 2000-byte UTF-8 cap.
698
+
699
+ No parameters.
700
+
701
+ **`set`** — Replace the org's business_context with ``content`` (admin/owner).
702
+
703
+ | Parameter | Type | Required | Description |
704
+ |---|---|---|---|
705
+ | `content` | string | yes | Full document content to save |
706
+
707
+ **`append`** — Append ``content`` to the existing business_context (admin/owner).
708
+
709
+ | Parameter | Type | Required | Description |
710
+ |---|---|---|---|
711
+ | `content` | string | yes | Text to append to the current document |
712
+ <!-- /dispatch-params -->