Agent skill

Malloy Dashboards

by malloydata in malloydata/publisher

Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and drill click-through.

MITAuto-check passedData & Analytics

Install Malloy Dashboards

skills CLI
$ npx skills add malloydata/publisher --skill malloy-dashboards -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install malloydata/publisher malloy-dashboards --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/malloy-dashboards .claude/skills/malloy-dashboards && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
malloy-dashboards
GitHub stars
116
Token cost
~8.4k tokens
SKILL.md length
5,026 words
Files
1
Skills in repo
29
Repo updated
First seen
Licence
MIT

At a glance

Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and drill click-through.

  • Works in 7 steps: READ THE MODEL FIRST. Get the real… → PICK THE VIEWS TO SHOW. A dashboard is… → DECLARE THE GIVENS the dashboard will… → …
  • The user asks for a dashboard
  • SKILL.md covers When this is the right tool, Build sequence, The form and Layout: the four tags that…, plus 7 more sections
  • Instructions only: no scripts, shell commands, URLs or credentials in SKILL.md

What it does

Malloy Dashboards is an agent skill from malloydata/publisher. Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and drill click-through. Use when the user asks for a dashboard, a filterable operational view, or drill-through between views, and no code is wanted.

Its SKILL.md is about 8.4k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Data & Analytics. The repository describes itself as: Publisher is the open-source analytics engine for Malloy. It lets you define data models once — and use them everywhere. The licence is MIT.

When your agent uses it

  • The user asks for a dashboard
  • A filterable operational view
  • Drill-through between views
  • No code is wanted

Example prompts

  • “/malloy-dashboards”

Workflow steps

7 steps, taken from the first numbered list in SKILL.md.

  1. READ THE MODEL FIRST. Get the real source, view, dimension, and given names from the package
  2. PICK THE VIEWS TO SHOW. A dashboard is ## artifact { tiles=[…] } naming existing views, so
  3. DECLARE THE GIVENS the dashboard will filter by, in the dashboard file itself, with their
  4. COMPOSE THE FILE for dashboards/, following the template below, but do not save it yet.
  5. COMPILE IT with your compile tool (POST …/models//compile over REST), against the source text,
  6. SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS. Your reload tool, or
  7. OPEN IT AND LOOK. Not optional; see "What 'done' means".

What it can do on your machine

Read from SKILL.md and the folder at commit b9a1a19. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    No scripts in the folder and no shell commands in SKILL.md (its code samples are malloy).

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Malloy Dashboards loads about 8.4k tokens when it runs. Until then it costs about 82 tokens; SKILL.md has 5,026 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~82
When it runs · the whole SKILL.md, loaded when a task matches
~8.4k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from malloydata/publisher at commit b9a1a19, republished under its MIT licence (© malloydata). 5,026 words, ~8,376 tokens.

Download SKILL.mdSave it as .claude/skills/malloy-dashboards/SKILL.md (or your agent's skills folder).
name
malloy-dashboards
description
Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and # drill click-through. Use when the user asks for a dashboard, a filterable operational view, or drill-through between views, and no code is wanted.
<!--
Copyright (c) Credible Data Inc.
SPDX-License-Identifier: MIT
-->

Publisher Dashboards

A dashboards/*.malloy file is a dashboard. It imports the model, names the views to show in ## artifact { tiles=[…] }, applies the filtering on those views, and tags the layout. Publisher discovers it at package load, renders the filter controls from the givens it references, offers # drill click-through between pages, and serves it at /<env>/<pkg>/dashboards/<name>. No code, no build step.

When this is the right tool

The user wantsUse
A recurring, at-a-glance view behind shared filtersthis skill (a dashboard)
A narrative, with prose between the numbersa notebook (skill:malloy-notebooks)
Custom design, branding, or interactions beyond tagsan HTML data app
The model itself: sources, measures, joinsmalloy-model

Notebooks and dashboards run the same engine, so interactivity is not the axis: both get filter controls, URL-addressable state, Apply batching, and # drill. Pick on the shape of the document. Scanned at a glance is a dashboard; read top to bottom is a notebook.

Build sequence

Tool names below are bare (get_context, compile_model, reload_package). Match each against the tools you actually have: a host may prefix or rename them. Where none matches, use the REST endpoint named beside it.

  1. READ THE MODEL FIRST. Get the real source, view, dimension, and given names from the package: get_context if you have it, otherwise the REST model endpoint for index.malloy (it answers 404 for a file off the surface) or the .malloy files. Never guess a name. A guessed field in a query fails the whole package load, not just that one dashboard; a guessed tile or suggest source is quieter, and only shows up in the package warnings. If the package root holds an index.malloy (every scaffolded package does), a tile reads only the sources that file exports. A source the dashboard file declares on top of an exported one works; one on top of a hidden source does not. See "Read the lint" for the warning you get otherwise.
  2. PICK THE VIEWS TO SHOW. A dashboard is ## artifact { tiles=[…] } naming existing views, so this is the design step: which views, how wide each sits, what each is called.
  3. DECLARE THE GIVENS the dashboard will filter by, in the dashboard file itself, with their control tags: see "Filter controls" below for the syntax and what each tag renders as. That is the convention because the dashboard builder edits the dashboard file and nothing else, so a filter it can add, change or remove is a declaration in that file. Bind them on the tiles (step 4). A given the model already declares and reads is not declared again: import it by name, and the builder can bind it but not edit it. See "Givens that stay in the model".
  4. COMPOSE THE FILE for dashboards/, following the template below, but do not save it yet. Name the sources you need: import { order_items, products } from '../storefront.malloy'. Each tile binds its controls with a refinement, view: t is v + { where: category ~ $CATEGORY }, one clause per given it answers to; a filter<...> binds with ~, a plain date or number given is a value and binds with >=, <= or =. Only the givens some tile references become controls, so a declaration nothing binds shows nothing. Then import every source or query any referenced given names in a suggest. Both are per-file, and getting the suggest wrong does not error: the control still looks like a picker but has no options, and says so underneath, "Could not load the options for this control". The package warnings name it too. A suggest naming a query= needs that query's own source imported as well, because an import is not transitive: the query resolves by name, so the file compiles and the package loads, but running the picker fails with Undefined source '<name>'. The package warnings name this one too, saying which source to import. Import the source that suggest query reads, not just the query.
  5. COMPILE IT with your compile tool (POST …/models/<path>/compile over REST), against the source text, before you save, at the path the file will have. Editing one that already exists needs "scope": "file", which compiles your source AS that file; the default appends it instead, so every imported name and the query name collide with the saved copy and you get a wall of already-defined errors that reads as broken Malloy rather than a wrong scope. Editing a shared include wants "scope": "package", which recompiles every file as saved: file only checks the one you are editing, so renaming a source in _shared.malloy passes it while breaking every dashboard that imports it. "scope": "file" does not check tiles, drills or suggest queries. To see those before you save, compile the same text at "scope": "package" with modelPath set to the dashboard's path: it runs the lint step 6 reads and returns its findings with code dashboard-lint (or render-tag), each at the severity a load gives it, so an error finding makes the compile an error too. Even that is not a working dashboard: some mistakes only show when you look at the page in step 7. A not-yet-saved dashboard wants "scope": "file" too, not the default: a dashboard file opens with an import, and the default append scope refuses one, so the new-file case fails on the import and again on the path that does not exist yet. append is for a fragment checked against a model that is already on disk, which a dashboard file is not.
  6. SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS. Your reload tool, or GET …/packages/<pkg>?reload=true over REST. Check the status the reload returns as well as the warnings: a 424 means the package did not load and your edit is not live. The warnings key is absent when there are none, so an empty response is the pass, not a sign you are reading the wrong field. Then read GET …/packages/<pkg>/dashboards/<name>: its givens are exactly the controls that will render, which catches a given you imported but never referenced before you open the page, and its query is the name to run in step 7. See "Read the lint" below.
  7. OPEN IT AND LOOK. Not optional; see "What 'done' means".

The form

A dashboard is ## artifact { tiles=[…] } at model level: named views, each run as its own query, laid out by Publisher into the grid # dashboard { columns=N } names.

malloy
##! experimental.givens
## artifact { title="Storefront overview" tiles=["overview -> kpis", "overview -> revenue_trend", "overview -> revenue_by_state"] } dashboard { columns=12 }
import { order_items, products } from '../storefront.malloy'

// The controls, declared here: the tags are each one's control contract.
# label="Category" control=select suggest { source=products dimension=category }
given: CATEGORY :: filter<string> is f''
# label="Ordered since"
given: SINCE :: date is @2023-01-01

// Layout goes on the VIEW, and a thin re-declaration is the place to put it: the
// modelled view keeps its chart tag, this decides how wide it sits here, and the
// `+ { where: ... }` says which controls the tile answers to.
source: overview is order_items extend {
  # colspan=12
  # label="Key figures"
  # big_value
  view: kpis is key_figures + { where: category ~ $CATEGORY, where: created_at >= $SINCE }

  # colspan=8
  # break
  # label="Revenue by month"
  view: revenue_trend is sales_by_month + { where: category ~ $CATEGORY, where: created_at >= $SINCE }

  # colspan=4
  # label="Revenue by state"
  view: revenue_by_state is sales_by_state + { where: category ~ $CATEGORY, where: created_at >= $SINCE }
}

Model-level because there is no query of its own to hang a # tag on, and model-level for a second reason: tiles run as separate queries, which is the only way a page can span unrelated sources. A nest's pipeline starts from its own query's source and there is no way to combine two.

Three things the form costs, so you are not surprised by them:

  • A tile expression is a string in an annotation, so the Malloy compiler never checks it. Rename a view and the file still compiles; the tile fails at package load. The lint names it: at step 6, or before saving with compile_model at "scope": "package" (step 5).
  • No per-parent-row grouping. There is no parent query to repeat a grid over.
  • Filtering lives on the tiles, not on the page: each view's + { where: ... } names the controls it answers to. Below is why, and the one thing that does not work.
Text tiles: prose between tiles

Prose between tiles is a named (markdown) block that tiles= lists with kind=text; it renders as a tile of its own, and its body is markdown:

malloy
## artifact { title="Storefront overview" tiles=[intro { kind=text colspan=6 break }, "overview -> kpis"] } dashboard { columns=12 }
##|(markdown) intro
## How to read this page
|##

The entry is name { kind=text colspan=6 break }: colspan and break lay it out like a query tile, and nothing else on the entry is read. The name is one bare word on the opener line, the body starts on the next line, and the |## closer sits at the opener's column. A heading goes inside the block, because a bare ## Heading line is a model tag. Keep the parentheses: ##|markdown draws a malformed-route warning. The lint reports an entry with no block, a block written twice, and a (markdown) block that no tiles entry names, so delete the last rather than leave it. The dashboard's own description is separate: the unnamed " notes above ## artifact. Earlier spellings are still read: ##|(text) name is a text tile, like ##|(markdown) name. Write (markdown).

In the API a text tile has kind: "text", a name and markdown, and no query; code that runs a dashboard's tiles skips it.

Legacy: # artifact on a query:

An older package may carry a single query tagged # artifact whose result is the whole page, laid out by @malloydata/render from the query's own # dashboard tag. It still renders. Do not author a new one, and do not convert an existing one unless asked: edit it minimally, and when it has to change shape, rewrite it as tiles=[…]. It cannot span sources, and a guessed field inside it fails the whole package load, where a bad tile in tiles=[…] fails alone, in its own cell. "Legacy single-query pages" near the end covers the few traps it has.

Where the filtering goes

A dashboard has no query, so its filtering lives on the views it names: a + { where: field ~ $GIVEN } refinement on each tile, one clause per control the tile answers to. A tile without the clause does not move when the control does, which is how a page keeps one tile fixed while the rest filter. The givens those clauses read are declared in the same file (step 3).

Binding is per declaration, not per name. Measured: a dashboard declaring its own CATEGORY over a source whose model-level where: reads the model's CATEGORY compiles, shows the control, and filters nothing when it moves, because the two declarations only share a name. So do not mix the two designs on one given. The other design still works on its own: a source with the givens already applied in an untagged dashboards/_shared.malloy, reading import '../givens.malloy', which discovery treats as a shared include rather than a dashboard. Then the dashboard imports both and the controls render for the givens its tiles reach. A given the dashboard imports but nothing references gets no control, silently, at reload 200 with no warning. Save the include before you compile the dashboard that imports it, since an importer compiled against a sibling that is not on disk fails with an import-error. The builder can bind such a control but not add, change or remove it, since it never edits imports or model files.

Note SINCE is a date rather than a filter<>, so it compares with >= rather than ~.

Givens that stay in the model

A given stays in the model, declared once there, when any of these reads it: a model source, view or measure; an #(authorize) or #(access_filter) gate; or an HTML data app. The dashboard imports it by name (import { CATEGORY } from '../givens.malloy') and never re-declares it. Getting this wrong fails in two different ways:

  • Import a name and also declare it: a compile error naming the clash. Drop the local declaration.
  • Declare a name the model already reads, without importing it: no error at all. The dashboard now has a second given that shares only the name. Its control renders and moves, and the model's own where: or gate never sees the value, so the filtering silently does nothing.

Declare locally only what no model code reads: a control that exists for this page's tiles. A drill into this page seeds a given by name, so the page declares that same name as filter<T>, with the type the clicked value fits (filter<string> for a category).

A composite declares in its own file any source it scopes itself, such as source: overview is order_items extend { … } above, so the givens its tiles bind are in the same file that binds them.

# dashboard { columns=N } is the spelling of the grid width, beside the artifact tag. dashboard_columns=N inside the artifact tag is a deprecated alias: it is read when columns is absent and draws a warning, and when the two disagree it is an error naming both values. Any other property inside the artifact tag that Publisher does not read is a package warning naming it.

A tile keeps its view's own field names on axes and column headers. # label titles the tile; to label what is inside it, label the fields in the view.

Layout: the four tags that make a page line up

Cards and tiles share one grid. Publisher reads these tags off the view each tile in tiles=[…] names. Copy this recipe and use the same count on every dashboard in the package so they read as one product:

  1. columns=12 on the # dashboard tag. Twelve divides by 2, 3, 4 and 6, so a row is even with three cards or four.
  2. A # colspan on every card and tile, summing to 12 per row. Four cards at 3, three at 4, two tiles at 6, a full-width table at 12. Omit them and every item falls to a single column, a twelfth of the width, which is too narrow for a line chart to draw in at all. A colspan wider than columns is clamped, and the package warnings say so.
  3. # break on the first tile after the cards. Otherwise it flows into the columns left beside the cards and the next tile wraps. Not needed per row: once a row sums to 12 the next item wraps on its own.
  4. # label="..." on every tile view and aggregate, including the aggregates inside a table view, whose column headers are field names too. The heading is otherwise derived from the view's or field's name, and a wide table full of total_sales and order_item_count is the most visible thing between a rough page and a finished one. A view referenced by name is the exception: you can label the tile, but its own field names still reach the chart axes and the column headers, so label the fields in the view if you want those too.

On a dashboard, all four go on the view a tile names, which is why a thin re-declaration (view: revenue_trend is sales_by_month) is the place to put them: the modelled view stays reusable and each page decides its own widths. # subtitle="..." and # borderless go there too and are the rest of the set; a tile reads all five.

Do not forget columns= itself. Without it the grid falls back to a narrow default and every colspan is clamped to it, which is the usual reason a page you laid out comes out one item per row, and the package warnings name it.

Then the traps:

  • A KPI row is a view of only measures. A tile is one whole result, so a tile that comes back as one row of measures renders as big-value cards on its own (# big_value on the view says so explicitly; # table opts out).
  • No # size=fill on a dashboard tile. Inside a dashboard it measures against the container the whole grid was handed, not the tile, so it yields a chart thousands of pixels tall. Tiles already size to their colspan.
  • A table tile sitting in the left part of a wide card with the rest blank is not filled by the dashboard. # table { size=fill } on the view forces it. That is the table's own tag, and it is a different thing from the # size=fill above, which is the chart one this page warns against.
  • A KPI card's label is one line that ellipses rather than wrapping, so a long label in a narrow card is truncated with no other sign. Widen the card or shorten the label.
  • A ratio needs a number format. order_count / customer_count renders as 10.695 on a card; # number="#,##0.0" is the precision it actually carries.
  • A # shape_map legend is titled with the measure's field name, not its # label. Rename it in the view: aggregate: revenue is total_sales. Renaming drops the measure's own format tags though, so # currency becomes plain digits unless you re-tag it at the rename. Prefer # label= anywhere the legend is not the problem.
  • A series legend sizes itself from the longer of the series label and its widest value, then truncates both. A 4-character label over 4-digit years clips to 20…; # label="Order year" instead of "Year" buys the room. A legend showing … is this, not a data problem.

The last two are upstream renderer behavior, cheap to work around in the model.

Height is the one thing the surface decides: a dashboard's tiles are each capped, and in a notebook a chart cell is capped and a table cell hugs its rows.

The rules that actually bite

  • The filename is the dashboard's name: its URL slug, its listing name, and its # drill target. The query inside can be called anything, and sometimes must be (a query named regions collides with an imported regions source).
  • A given has to be in the dashboard file's scope to be bindable: declared there (the convention) or imported. Malloy's given namespace is per-file. A given the file cannot see gets no control and cannot be sent to it, even when a where: that references it lives up an import chain. And a given declared here does not drive a where: in the model: bind on the tiles.
  • A suggest's source or query has to resolve in the dashboard file too. suggest { source=products … } means the dashboard imports products.
  • A model-level ## tag must be on one line. Wrapping one always breaks it, but how you find out depends on what follows. If the continuation is not valid Malloy you get a compile error. If it happens to be, an import say, the file compiles clean, quietly stops being a dashboard and becomes a shared include, and only the package warnings tell you. Match on the shape rather than the words: the message may say a tag "does not parse", or was "refused" or "dropped rather than parsed", and on a file that still built it opens "Annotation" rather than "Tag". See "Legacy single-query pages" below.
  • Only table cells are marked drillable. See "Drill".

skill:malloy-charts covers the renderer tags and choosing them.

Show full SKILL.md (2,019 more words)Show less

Filter controls

Controls come from the given: declarations the tiles reference. Declare the new ones in the dashboard file, which is what the builder edits; a given the model already declares is imported by name and gets the same control, which the builder can bind but not edit. The tags on the declaration are the control contract:

malloy
##! experimental.givens

# label="Category" control=select suggest { source=products dimension=category }
given: CATEGORY :: filter<string> is f''

# label="Brand" control=multiselect suggest { query=brand_suggest dimension=brand }
given: BRAND :: filter<string> is f''

# label="Minimum line total" range_min=0 range_max=250
given: MIN_SALE :: filter<number> is f''

# label="Ordered since"
given: SINCE :: date is @2023-01-01

A sixth tag, description, is part of the contract and has two spellings that do different things: # description="…" publishes to the API but Publisher's own UI does not render it, while #(description="…") renders as helper text under the control but complains about any multi-word value, that the prefix "is not a well-formed route", because a route ends at the first space. That complaint is a compile diagnostic on a compile that still succeeds, not a package warning, so step 6 will not show it. Pick by which reader you care about.

dimension= can name a field of a directly joined source (one join level). Quote it, because an unquoted dotted value does not parse as a tag: suggest { source=sales dimension="products.department" }.

On the query= form of suggest, dimension names a column of that query's output, not a path into the model, and only the last segment of it is read. Match it to the suggest query's group_by. When it matches no column the picker falls back to the first column of the result: silently right for a one-column suggest query, silently wrong for a multi-column one.

control=select/multiselect with a suggest renders a picker filled from the data; range_min/range_max on a filter<number> renders a two-handled range slider; a filter<date> or filter<timestamp> renders a time-range control with preset windows and a custom day range; a bare date or timestamp renders a date picker. Which controls appear is per-dashboard, decided by which givens the query references. malloy-model (a modeling skill) and docs/givens.md cover givens themselves.

Two per-dashboard options on the artifact tag:

  • autorun=false batches control changes behind an Apply button. Add it once a page is slow enough that a reader notices two round trips.
  • givens { CATEGORY=f'Outerwear' } sets starting values, not a redeclaration. A URL parameter wins.

A served .malloy notebook takes both inside its ## artifact { … } tag, as autorun=false and givens { CATEGORY=f'Outerwear' }, and behaves identically. A file-level ## autorun=false does nothing there; only a legacy .malloynb reads options at the file level.

Drill

# drill goes on a model dimension, never on a dashboard:

malloy
# drill { to=["category", "self"] given=CATEGORY }
dimension: category is products.category

to=<slug> navigates to that dashboard with the clicked value written into the named given; to=self filters in place; two or more destinations pop a menu.

Declaring it on the dimension is the point: every result that groups by it becomes clickable, in a dashboard tile and in a notebook cell alike. So when a view is meant to be drilled, group by the tagged dimension. Declaring dimension: category is products.category and grouping by category gives the identical output field name and the identical numbers, and carries the tag.

Always write given=. Without it the given name is the dimension name verbatim, so a dimension: category seeds a given called category rather than a declared given: CATEGORY. A to=self survives that, because a surface folds case when it looks up its own given. A to=<slug> does not: it navigates, still looks like it worked, and arrives as ?category=…, which the destination drops by exact match, so you land on an unfiltered page. Nothing errors, and the lint folds case when it checks, so it stays green too. That silence is specific to a name that folds onto a declared given. A to=self whose name matches nothing at all is caught loudly and is not offered; a to=<slug> is not checked either way.

A drill only lands somewhere useful if the destination declares a control for the given being seeded. No lint checks that. It verifies that the target slug is a dashboard in the package, and for to=self that some model declares the given, and stops there. Nothing reads the destination's own givens, so click it and look.

Cells in a drillable table column show it: pointer cursor, and a blue underline on hover. They are in the tab order and carry a button role too, so a keyboard reaches them, focus is styled the way hover is, and Enter or Space fires the drill. Chart marks get no such affordance in either Publisher or Malloyyo, so a dashboard meant to be drilled wants at least one untagged (table) tile. A destination the surface cannot honor is not marked and not offered, which is why a to=self reads as plain text in a document that declares no control for its given.

One thing quietly switches the marking off, per column: another tile rendering a column with the same header. Put a "revenue by category" chart beside a drillable category table, which is the obvious thing to build, and that column's cells stop being marked. Marking matches columns by their rendered header text, so a name a non-drillable field also shows is dropped rather than risk painting a dead link. Only a non-drillable column suppresses: a second tile that groups by the same tagged dimension is fine, which is the arrangement the paragraph above already recommends. Other drillable columns on the same page keep their marking, so counting marked cells will not tell you: look at the column you care about. The clicks still work, so this is invisible unless you hover. Give the two different headings with # label=. A transposed table is never marked either, for a different reason.

Legacy single-query pages

Only for a package that already has one (see "Legacy" above). Its layout can come out wrong in two visibly different ways, and the reload is 200 and the manifest reports the column count you asked for in both. A top-level aggregate: there is the KPI card; nest views for tiles; give each a # colspan and the first tile after the cards a # break; # dashboard { gap=N table { max_height=N | none } } are options only this form reads.

Not a dashboard at all: one plain nested table, every # colspan and # break dropped, because a f'…' filter literal in a givens { … } block shares a line with # dashboard. Put # dashboard on its own line.

A dashboard, but nothing lines up: # colspan without columns=N, so the items flow side by side at their natural widths instead of aligning to a grid.

To tell them apart, run the page's own query, {"queryName": "<the manifest's query>"}, and read renderLogs on the response (absent when there is nothing to say):

render logwhat it means
Unknown render tag 'colspan'the renderer never saw a # dashboard tag. It does not say which of the two causes; check both
Ignored # colspan … only applies in columns modeit saw the tag but there is no count

Neither reaches the package warnings. A wrapped ## tag is the one failure in this family that does: the file is absent from the listing and the package warnings say "Tag does not parse (Unclosed '{')".

Read the lint

Package warnings after a reload are the dashboard's test suite. Fix all of them:

  • # drill … targets "x", which is not a dashboard in this package: a dead click.
  • to=self, but no model in this package declares a given "X": the clicked value has nowhere to go.
  • given "X" suggests options from source "y", which this file does not define: the dropdown will be empty, so import it.
  • filters by given "X", which this file does not import, so no control is shown for it: the trap under "Importing a given is what makes it bindable", which the lint now names for you, with the file to fix.
  • A tile that does not resolve to a real view; a non-positive # dashboard { columns }; a tile view's # colspan that is not a positive integer or is wider than the grid; a dashboard_columns= alias, which is an error when it disagrees with dashboard { columns }; and any property inside the artifact tag Publisher does not read.

Findings carry a severity, but warn is the ordinary default and tells you nothing about how bad one is. Read the text, not the severity and not the count. One message is worth recognising because it changes what the rest of the list means: "Dashboard lint stopped early, so this list is incomplete".

Read the status the reload itself returns, not the listing. One dashboard that fails to compile fails the whole package load, and the reload answers 424 with the compile error. A package that was already serving then keeps serving its previous version, so the listing still answers 200 and looks perfectly healthy while your edit has silently not taken effect.

If you did not catch the 424, GET /api/v0/status still knows. A package serving an older model than its files appears in loadErrors with stale: true, the compile message, and the time it failed, and the entry clears on the next reload that compiles. That is the one check that works after the fact, so make it the first thing you run when a page will not change. A package that never loaded at all appears there too, without stale, and is absent from the listing entirely.

If the reload is 200 and the others are listed but yours is not, discovery skipped the file, usually a missing or misspelled # artifact tag. That is the same mechanism that deliberately skips an untagged shared include, and it is also how you hide a dashboard on purpose: drop its tag. The package surface never hides a dashboard. Every tagged dashboard is listed and served. Dropping the tag hides it as a dashboard, but not always as a file: with no index.malloy, or with a legacy explores that lists it, the untagged file is then listed and published as an ordinary model.

A tile over a hidden source answers 404. When the package has an index.malloy, tiles, a legacy single-query page's query, and filter suggest lists read only the sources it exports. The dashboard file admits nothing of its own, so importing a source into it is not enough. The package load warns once per tile that will fail, for example:

Tile orders_staging -> by_flag on dashboard overview reads orders_staging, which index.malloy doesn't export, so it won't load. Fix: add orders_staging to the export { ... } in index.malloy.

The fix is the one the warning names: add the source to the export { ... } in index.malloy. In a package that gates a source with #(authorize) or #(access_filter), the warning says "a source" instead of naming it. A suggest over a hidden source gets the same warning, ending "so its list will be empty". Do not add an explores to publisher.json to serve a dashboard; it is deprecated and no longer needed.

A clean reload is not proof the tags are right. The checks above read names and resolve them; the separate warning for a tag that does not parse is syntax only: it carries no position and says nothing about a name that does not resolve. It catches a malformed tag; its absence is not evidence there are none. That is why the last step is opening the page, not reading the warning list.

What "done" means

  • Every source, view, and field name came from the model you read in step 1.
  • The reload returned 200, not 424. A 424 means the page you are about to look at is the old one.
  • The package reloads with zero dashboard warnings, and your dashboard is in the listing at all.
  • The page's items line up. Open it and look: the checks above pass on a dashboard whose items do not align.
  • You opened the page and every tile shows real numbers: not stuck loading, not an error, not an empty state you did not intend.
  • Each control renders as the widget you intended (a select shows options; a slider is a slider), and changing one changes the numbers.
  • If you added a # drill, you clicked it and landed where you meant to, with the given seeded.
  • Every card and tile carries a colspan, each row's colspans sum to columns, and the rows end flush with each other. Nothing is clipped, no tile is thousands of pixels tall, and no legend or card label ends in ….

Reference

  • docs/dashboards.md: the full guide this skill condenses.
  • docs/givens.md: the givens the controls are generated from.

© malloydata, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in skills/malloy-dashboards of malloydata/publisher.

Open the folder on GitHubat commit b9a1a19

Compare with similar skills

Malloy Dashboards next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Malloy Dashboards compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Malloy Dashboards this skillmalloydata/publisher116—~8.4kAutomated safety check: PassMIT
MatplotlibzLanqing/codex-claude-academic-skills4.7k17 repos~2.9kAutomated safety check: PassMIT
Exploratory Data Analysisspacering-net/codeg3.9k14 repos~3.6kAutomated safety check: PassMIT
Scikit LearnzLanqing/codex-claude-academic-skills4.7k16 repos~3.9kAutomated safety check: PassBSD-3-Clause
Chart Visualizationbytedance/deer-flow84k2 repos~840Automated safety check: PassMIT
TimesFM Forecastinggoogle-research/timesfm34k—~4.7kAutomated safety check: PassApache-2.0

Similar skills

  • Matplotlib

    zLanqing/codex-claude-academic-skills

    Low-level plotting library for full customization. An agent skill from zLanqing/codex-claude-academic-skills.

    4.7k GitHub starsUsed in 17 repos~2.9k tokens
    Data & AnalyticsAuto-check passed
  • Exploratory Data Analysis

    spacering-net/codeg

    Perform comprehensive exploratory data analysis on scientific data files across 200+ file formats.

    3.9k GitHub starsUsed in 14 repos~3.6k tokens
    Data & AnalyticsAuto-check passed
  • Scikit Learn

    zLanqing/codex-claude-academic-skills

    Machine learning in Python with scikit-learn. An agent skill from zLanqing/codex-claude-academic-skills.

    4.7k GitHub starsUsed in 16 repos~3.9k tokens
    Data & AnalyticsAuto-check passed
  • Chart Visualization

    bytedance/deer-flow

    Picks a suitable chart type from 26 options for your data, maps the data to that chart's parameters and generates a chart image through a JavaScript script.

    84k GitHub starsUsed in 2 repos~840 tokens
    Data & AnalyticsAuto-check passed
  • TimesFM Forecasting

    google-research/timesfm

    Forecasts any univariate time series zero-shot with Google's TimesFM model, returning point forecasts and calibrated prediction intervals without training.

    34k GitHub stars~4.7k tokensUpdated 10 days ago
    Data & AnalyticsAuto-check passed
  • Sandbox Bench

    vercel/next.js

    Official

    Benchmark React or Next.js changes on Vercel Sandbox VMs with paired A/B statistics: react PR/commit vs base, or Next.js PR/commit vs base, measured end-to-end through the bench/render-pipeline app…

    143k GitHub stars~4.1k tokensUpdated today
    Data & AnalyticsAuto-check passed

More from malloydata/publisher

All 29 skills in this repo
  • Eval Answer

    malloydata/publisher

    Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer.

    116 GitHub stars~4.3k tokensUpdated today
    Auto-check passed
  • Fix Scan Finding

    malloydata/publisher

    Fix a CRITICAL Trivy finding that is failing CI in this repo (a vulnerability, misconfiguration, or secret from security-scan.yml or image-scan.yml), or add, review, or retire an entry in…

    116 GitHub stars~5.1k tokensUpdated today
    Auto-check passed
  • Eval Import

    malloydata/publisher

    Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs.

    116 GitHub stars~5.9k tokensUpdated today
    Auto-check passed
  • Eval Loop

    malloydata/publisher

    Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint.

    116 GitHub stars~7.8k tokensUpdated today
    Auto-check passed
  • Eval Improve

    malloydata/publisher

    Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim.

    116 GitHub stars~2.8k tokensUpdated today
    Auto-check passed
  • Eval Judge

    malloydata/publisher

    Decide whether ONE answer matches its golden, and say whether you believe the golden.

    116 GitHub stars~3.4k tokensUpdated today
    Auto-check passed

Questions about Malloy Dashboards

What does Malloy Dashboards do?

Build or modify a Malloy Publisher dashboard, a tagged .malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and drill click-through. Malloy Dashboards is an agent skill from malloydata/publisher.malloy file in a package's dashboards/ directory, with auto-rendered filter controls, a grid layout, and drill click-through.

When should I use Malloy Dashboards?

Malloy Dashboards fits situations like: the user asks for a dashboard; A filterable operational view; drill-through between views; no code is wanted.

How do I install Malloy Dashboards in Claude Code?

Run `npx skills add malloydata/publisher --skill malloy-dashboards -a claude-code`. Or copy the skill folder (skills/malloy-dashboards in malloydata/publisher) into .claude/skills/malloy-dashboards in your project. Claude Code loads it when a task matches its description.

How do I install Malloy Dashboards in Codex?

Run `npx skills add malloydata/publisher --skill malloy-dashboards -a codex`. Or copy the skill folder (skills/malloy-dashboards in malloydata/publisher) into .agents/skills/malloy-dashboards in your project. Codex loads it when a task matches its description.

Can I use Malloy Dashboards in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add malloydata/publisher --skill malloy-dashboards -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/malloy-dashboards, .gemini/skills/malloy-dashboards, .github/skills/malloy-dashboards and .opencode/skills/malloy-dashboards in your project.

What does Malloy Dashboards need to run?

SKILL.md names no scripts, command-line tools or credentials: Malloy Dashboards is instructions for the agent only.

Does Malloy Dashboards access the network?

SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.

Is Malloy Dashboards safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Malloy Dashboards use?

Malloy Dashboards is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Malloy Dashboards use?

About 8.4k tokens (SKILL.md is roughly 34k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Malloy Dashboards?

Skills that share tags, products or a category with Malloy Dashboards: Matplotlib (zLanqing/codex-claude-academic-skills, 4.7k stars), Exploratory Data Analysis (spacering-net/codeg, 3.9k stars), Scikit Learn (zLanqing/codex-claude-academic-skills, 4.7k stars) and Chart Visualization (bytedance/deer-flow, 84k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Malloy Dashboards?

malloydata (a GitHub organization) maintains it in malloydata/publisher, which has 116 GitHub stars. The repository holds 29 skills in this directory. The repository was last updated on October 9, 2026.

Source: malloydata/publisher on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.