Agent skill

Malloy Model

by malloydata in malloydata/publisher

Build Malloy semantic models with base source and joined source files.

MITAuto-check passedLegal & Compliance

Install Malloy Model

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

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

GitHub CLI
$ gh skill install malloydata/publisher malloy-model --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-model .claude/skills/malloy-model && 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-model
GitHub stars
116
Token cost
~6.9k tokens
SKILL.md length
3,441 words
Files
6
Skills in repo
29
Repo updated
First seen
Licence
MIT

At a glance

Build Malloy semantic models with base source and joined source files.

  • Modifying .malloy files
  • SKILL.md covers Getting Started (New Projects), Prior Art Dispatch, Base Source Templates and Joined Source File Template, plus 9 more sections
  • Instructions only: no scripts, shell commands, URLs or credentials in SKILL.md
  • User asks to create a malloy model

What it does

Malloy Model is an agent skill from malloydata/publisher. Build Malloy semantic models with base source and joined source files. Use when creating or modifying .malloy files, user asks to "create a malloy model", "add dimensions", "add measures", "create a source", or any Malloy model authoring task.

Its SKILL.md is about 6.9k tokens, which your agent loads only when the skill is triggered. The skill folder holds 6 other files (for example `reference/access-modifiers.md`, `reference/analysis-to-model.md` and `reference/bridge-tables.md`).

It sits in Legal & Compliance. 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

  • Modifying .malloy files
  • User asks to create a malloy model
  • Create a source
  • Any Malloy model authoring task

Example prompts

  • “create a malloy model”
  • “add dimensions”
  • “add measures”
  • “/malloy-model”

What it can do on your machine

Read from SKILL.md and the folder at commit 39a546f. 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 Model loads about 6.9k tokens when it runs. Until then it costs about 64 tokens; SKILL.md has 3,441 words of instructions outside code blocks.

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

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 39a546f, republished under its MIT licence (© malloydata). 3,441 words, ~6,893 tokens.

Download SKILL.mdSave it as .claude/skills/malloy-model/SKILL.md (or your agent's skills folder). This skill also uses 5 other files; get the full folder from GitHub.
name
malloy-model
description
Build Malloy semantic models with base source and joined source files. Use when creating or modifying .malloy files, user asks to "create a malloy model", "add dimensions", "add measures", "create a source", or any Malloy model authoring task.
<!--
Copyright (c) Credible Data Inc.
SPDX-License-Identifier: MIT
-->

Building Malloy Models

Tool names are written bare here - get_context, execute_query, search_malloy_docs. The exact prefixed name depends on the host surface; match each against the tools you actually have.

Getting Started (New Projects)

If no .malloy files exist yet, do discovery and propose a structure first, then return here to build base source and joined source files. Keep proposals and the analysis behind them in the conversation.

File structure convention (a flat layout at the package root is the simplest default):

<package-name>/
  publisher.json              # Required for publishing (name, version, description)
  customers.malloy            # Base source: one per table
  products.malloy
  orders.malloy
  user_order_facts.malloy     # Computed source
  order_analysis.malloy       # Source: one per analytical domain
  customer_health.malloy

Versions for new packages should start at "0.0.1".

Before creating any files, check for an existing publisher.json in the target directory. If one exists for a different package, create a new subdirectory for your package, don't overwrite another package's config.

In Publisher an environment is a project, and Publisher is single-tenant, so there is no org/tenant layer to model around: one environment holds one set of packages.

Prior Art Dispatch

If your discovery turned up existing modeling patterns to mirror (a derived table, UNNEST joins, a review or curation pass), read the relevant reference before building.

Pattern found in prior artReference to read
Derived table (PDT/NDT)skill:malloy-lookml-review build-derived-tables guidance
UNNEST joins or struct accessskill:malloy-lookml-review build-unnest guidance
Review pass for coverageskill:malloy-lookml-review review-coverage guidance
Curate pass with visibility seedsskill:malloy-lookml-review curate-visibility guidance

Base Source Templates

Base Source (Simple Mode)
malloy
source: customers is my_conn.table('sales.customers')
extend {
  primary_key: customer_id

  dimension:
    // A dimension is the lighter way to give a column a cleaner name
    order_type is `Type`
    full_name is concat(first_name, ' ', last_name)
    segment is lifetime_value ?
      pick 'enterprise' when >= 100000
      pick 'mid-market' when >= 10000
      else 'SMB'

  measure:
    customer_count is count()
}

Every dimension: needs name is expr. A bare column name like dimension: species is a parse error (unexpected '}', or missing 'is' before 'measure:' when another declaration follows). Raw columns are already usable in group_by / select without any declaration, so only add a dimension: when deriving or renaming a field (e.g. revenue is price * quantity).

Base Source (Curated Mode with Access Modifiers)
malloy
##! experimental.access_modifiers

source: orders is my_conn.table('sales.orders')
include {
  public:
    #(doc) Order identifier
    order_id

    #(doc) Customer who placed the order
    user_id

    #(doc) Total sale price in USD
    sale_price

    #(doc) Order creation timestamp
    created_at

  internal:
    raw_payload_json  // Verified empty via index query + user confirmation
}
extend {
  primary_key: order_id

  dimension:
    #(doc) Date the order was placed
    order_date is created_at::date

  measure:
    #(doc) Total number of orders
    order_count is count()

    #(doc) Total revenue in USD
    # currency
    revenue is sum(sale_price)
}
Computed Source (from Query)

Wrap the query in parentheses and extend it. from(...) was removed from the language and no longer parses (unexpected 'from').

malloy
import "orders.malloy"

source: user_order_facts is (
  orders -> {
    group_by: customer_id
    aggregate:
      total_orders is count()
      total_revenue is sum(sale_price)
      first_order_date is min(created_at)
      last_order_date is max(created_at)
  }
) extend {
  primary_key: customer_id

  dimension:
    days_since_last_order is days(last_order_date to now)
    is_repeat_buyer is total_orders > 1

  measure:
    buyer_count is count()
    avg_customer_ltv is avg(total_revenue)
}

For advanced query-based source patterns (window functions, pipelines), see reference/query-sources.md.

Joined Source File Template

malloy
import "customers.malloy"
import "orders.malloy"
import "user_order_facts.malloy"

#(doc) Customer health analysis. Use for retention, segmentation, and churn risk.
source: customer_health is customers extend {
  join_one: user_order_facts with customer_id
  join_many: orders on customer_id = orders.customer_id

  dimension:
    is_at_risk is user_order_facts.days_since_last_order > 90
      and user_order_facts.total_orders > 1

  measure:
    revenue_per_customer is orders.sale_price.sum() / nullif(customer_count, 0)
    at_risk_count is count() { where: is_at_risk = true }
}

Base vs Joined Sources

Base Joined Source FileJoined Source File
ContainsOne table's fieldsJoins between base sources
DimensionsIntrinsic to this table onlyCross-source (require joins)
MeasuresSingle-table aggregationsCross-source aggregations
JoinsNone (or only lookup joins intrinsic to the source)Defines relationships between base sources
ViewsNone, in schema-first (see below)None, in schema-first (see below)
One perPhysical table or computed sourceAnalytical domain

The "no views" rule is schema-first only. A schema-first model is built before anyone has asked a question, so any view in it is a guess. It does not apply to the analysis-first workflow (skill:malloy-model-as-you-go), where every view is a question that was asked and verified - there, saving a view or a dashboard is the right call, in the model file next to the measures it uses. Analysis-first still models everything else properly: documented dimensions, measures, and joins.

Two more rules here are schema-first only. Analysis-first should skip access modifiers and curation - there is no discovery surface to curate when every field was paid for by a question - and skip one-file-per-table, keeping a single domain file until it genuinely gets unwieldy.

Key Rules

  • Every dimension: needs name is expr: a bare dimension: species is a parse error. Raw columns are queryable directly in group_by / select; only declare a dimension: to derive or rename a field.
  • Define joined tables before referencing them, use import statements in multi-file architecture
  • Use nullif(denominator, 0) for all division
  • Alias joined fields before using in order_by: group_by: yr is table.year
  • Verify join paths exist before referencing a.b.field (each hop needs explicit join)
  • Pick syntax: value BEFORE condition, pick 'Small' when size < 10
  • where: vs having:: Use where: for row filters, having: for aggregate filters
  • rename: composes with include {}, but only in one order: the extend { rename: } must come before the include {}, which then names the field by its new name. Reversed, it fails with Can't find field 'X' to set access modifier. For a cleaner column name without a rename, internal: + dimension: is still the lighter move (mark `Type` as internal, add dimension: order_type is `Type`). See skill:malloy-gotchas-modeling § Field Management
  • Mark raw columns internal when a derived dimension replaces them
  • Check for duplicate rows before building measures
  • When both a combined table (all types) and filtered/split tables exist, prefer the split tables
  • DRY: define measures/dimensions in base source files, not inline in views
  • Lay out a new file the same way throughout: two-space indentation, no tabs, one blank line between top-level declarations (consecutive import lines stay together), no trailing whitespace, and a long line broken after a comma or before an operator, at whatever width the project keeps to. In a project whose files already use another layout (tabs, four spaces), a new file matches them.
  • An edit keeps the file's own layout: change only the lines the request needs, and never reindent or rewrap a line you weren't asked to change, so the diff shows the change and nothing else.
  • Never write a threshold, tier boundary, or bucket cutoff you chose yourself. Every boundary in a pick expression or filtered measure is user-supplied, distribution-derived (query min/p25/p50/p75/p95 first and show the evidence; Malloy has no percentile function, so use the two-stage query in skill:malloy-discover § Example Queries; see skill:malloy-define § Data-driven proposals), or explicitly flagged as an assumption in its #(doc). A hardcoded cutoff nobody confirmed is a business decision shipped as fact.

Parameterizing sources with given: (preferred)

Native Malloy given: parameters are how you expose tunable knobs (date range, region, manufacturer) on a source. #(filter) is deprecated: never add one. A given: is a first-class runtime parameter you reference in the model's own logic; callers supply values at query time and the model uses them however it declares. Enable them with ##! experimental.givens at the top of the model.

malloy
##! experimental.givens

given:
  manufacturer_filter :: filter<string> is f''
  subject_filter :: filter<string> is f''

source: recalls is duckdb.table('data/auto_recalls.csv') extend {
  where: Manufacturer ~ $manufacturer_filter, Subject ~ $subject_filter
  measure: recall_count is count()
}

A given is declared bare but referenced with a $ sigil in expressions ($manufacturer_filter), as above.

  • Give every optional filter a neutral, match-all default - a filter<> given defaulting to f'' - so an unsupplied value returns unfiltered rows, matching how #(filter) behaves when a value is omitted. Because the given bakes an always-on where: into the source, a non-neutral default (e.g. a date floor) applies to every read of the source, not just the ones that opt in - so keep defaults neutral. Defaults must be Malloy literals.
  • Givens don't auto-inject a where:. Unlike #(filter), you write the filter expression that references the given yourself (e.g. where: dimension ~ $given_name).
  • Every #(filter) use has a given: form. Never add a #(filter) annotation to a model, not even for required, implicit, or a date/number range:
You wantWrite thisNot this
An optional value or list filtergiven: REGION :: filter<string> is f'' and where: region ~ $REGION#(filter) dimension=region type=in
A date or number rangegiven: MIN_SALE :: filter<number> is f'' (or filter<date>) and where: sale_price ~ $MIN_SALE. The caller sends a filter expression such as >= 50.two #(filter) lines, greater_than and less_than
A value every query must supply (the primary key is only unique within it, or the table is too big to scan whole)a given with no default, e.g. given: EVENT_DATE :: date and where: event_date = $EVENT_DATE. A query that omits it fails with "Given 'EVENT_DATE' has no value and no default".#(filter) ... required
A row filter the system applies, not the caller#(access_filter) org_id in $ORG_IDS, with ORG_IDS set by a trusted tier (see the trust caveat below)#(filter) ... implicit
  • Across files, a given travels by name. A file that imports another reaches a given only by importing it: a whole-file import "x.malloy" brings every given x.malloy declares, and a selective import { src } from "x.malloy" brings only what it names. The source still compiles either way, because Malloy carries the declaration underneath, but a caller can set only a given the entry model has in scope. In a package curated with index.malloy, that means index.malloy imports the declaring file whole or names the given in its selective import. Imports don't chain: a given the declaring file itself imports from elsewhere has to reach index.malloy too.

Givens are also the substrate for access control - see "Access Control: #(authorize) and #(access_filter)" below.

Legacy: reading an existing #(filter) model

#(filter) is deprecated. Do not add one, and do not copy one from an existing model into a new source. This section is here so you can read, call, and migrate a model that already has them.

Publisher parses the annotation, lists the filters in the API, and injects a where: clause server-side when a caller passes a value (filterParams / filter_params). The annotation sits above the source: line:

malloy
#(filter) [name=NAME] dimension=DIMENSION type=TYPE [implicit] [required]
PartMeaning
nameThe API parameter key. Defaults to the dimension name.
dimensionThe dimension the filter targets.
typeequal (=), in (any of several values), like (~ '%value%'), greater_than (>, exclusive), less_than (<, exclusive).
requiredThe server returns 400 when a query supplies no value.
implicitHidden from the UI and the API filter list.

Publisher formats the value from the dimension's type ('value', bare true/false, @YYYY-MM-DD), so a caller passes it unquoted.

#(filter) is not a security boundary. A caller skips it with bypass_filters=true (REST) or bypassFilters: true (POST body), or by writing their own query text. Use givens with #(authorize) / #(access_filter) for access control.

To migrate a source, replace each annotation using the table above, then delete it. type=greater_than and type=less_than are exclusive, so a plain comparison you convert one to is > or <, not >= or <=. docs/givens.md § "Coming from #(filter)" has a worked conversion.

Show full SKILL.md (1,891 more words)Show less

Access Control: #(authorize) and #(access_filter)

Gate query access to a source over declared given: values (given: is Malloy's native runtime-parameter mechanism, the going-forward replacement for #(filter)). Two annotations, one question each, and the name tells you which:

AnnotationThe questionBody it takesA denial is
#(authorize)may this caller reach this source at all?'literal' <op> $GIVEN, plus the true/false sentinels403
#(access_filter)which rows may they see, once they may?field_path <op> $GIVEN200, with their rows

Either is an annotation on its own line directly above the source: line, carrying a narrow grammar publisher parses itself, not an arbitrary Malloy expression: one or more terms joined only by and, with <op> fixed by the given's declared arity (in for a list, = for a scalar).

Nothing is inferred from the body. The annotation you write declares which question you are answering, and a body that does not fit is refused at load naming the other annotation. A source with no annotation of either kind, own or inherited, is unrestricted.

The lock is DECIDED, before the caller's query compiles: a caller it does not admit gets a 403, never a row count or a NULL aggregate over data they were refused. The filter is GRAFTED onto the query as a where:, so a caller it matches nowhere gets a 200 with an empty result. The lock runs first; a caller it refuses never reaches the filter. A 403 also covers either gate failing to apply at all (a field the entry point dropped, a given nobody supplied).

malloy
##! experimental.givens

given:
  ROLE :: string
  ORG_IDS :: string[]

#(authorize) 'analyst' = $ROLE
#(access_filter) org_id in $ORG_IDS
source: orders is duckdb.table('orders.parquet') extend {
  measure: order_count is count()
}
  • Nothing outside the two term shapes above parses. No or, not, !=, </>/<=/>=, function calls, bare field/boolean references, or a literal on the right of a row-level term. org_id in $GROUPS and region = $REGION and org_id in $GROUPS are legal; upper(region) = $REGION, (org_id in $GROUPS or region = $REGION), and a bare #(access_filter) authorized are all refused at load with a named cause (see your deployment's reference documentation for the full list).
  • Writing a term on the wrong annotation is refused, both ways. #(authorize) org_id in $GROUPS is refused naming #(access_filter); #(access_filter) 'finance' in $GROUPS is refused naming #(authorize). The second matters: a constant predicate grafted as a row filter would serve a refused caller 200 with zero rows, which is the answer the lock exists to replace.
  • A source may declare more than one note on a route: repeats AND together. #(access_filter) region = $REGION stacked with a second #(access_filter) org_id in $GROUPS both apply, and a caller must satisfy every term across every note. or is still refused wherever it appears, so there is no way to spell "admit if either" inside one gate or across a source's gates; use two extension sources instead, one per admitted population (see the admin pattern below).
  • Two exceptions to "every term references a given", and both live on the lock. #(authorize) false is a deny-all: that is how you lock a base. #(authorize) true is an admit-all, and it is what an extension of a locked base needs in order to be open: a source declaring no gate of its own inherits its ancestor's, so leaving the annotation off an extension of a false base inherits the lock rather than lifting it. Neither sentinel is legal on #(access_filter). false may not share a source with another note at all; true may not share the lock route with one, but is live beside an #(access_filter) (#(authorize) true with #(access_filter) org_id in $GROUPS re-opens a locked base while still scoping the rows). Neither is a term: true and org_id in $GROUPS is read as an ordinary two-term body whose first term is malformed.
  • Inheritance is per route. Both inherit through extend the same way, own wins over ancestor on that route only, so a source can be "own" for one and "inherited" for the other. The API reports them separately, each under the field named for its own annotation: Source.authorize and Source.accessFilter.
  • Generate the admin escape hatch as another extension over the locked base, never as a bypass. When a model needs an ordinary population plus a wider one, add a second extension whose rule is a lock on the caller:
malloy
given:
  ORG_IDS :: string[]
  GROUPS  :: string[]

#(authorize) false
source: orders_base is duckdb.table('orders.parquet') extend {}

#(authorize) true
#(access_filter) org_id in $ORG_IDS
source: orders is orders_base extend {}

#(authorize) 'admin' in $GROUPS
source: orders_admin is orders_base extend {}

Each extension replaces the base's false on the lock route with its own rule. orders is open to everyone and scoped per row; orders_admin is locked to the admin group and, carrying no filter, serves every row to whoever passes. This is the shape to generate whenever an author wants a role to see everything: another extension source, not a flag that skips the gate.

  • Both only gate from the source: line. The same annotation on a dimension:/measure:/join_*:/view: line, or on a top-level query:, is refused at load naming the position rather than silently protecting nothing.
  • Every given the gate references must be declared on the entry model's own surface, and must carry no default. In a package curated with index.malloy, "on the surface" means index.malloy imports it: import the declaring file whole, or name the given in a selective import (import { orders, GROUPS } from "orders.malloy"). A given the model cannot resolve is refused at load. So is a referenced given declared with a default: a caller who supplies nothing would get that default and be admitted or excluded by a value the gate's own line never shows, so it is refused rather than reasoned about case by case.
  • A scalar/array mismatch between the operator and the given's declared type is a load-time refusal, not a request-time warehouse error: org_id in $GROUPS requires GROUPS to be array-typed, region = $REGION requires REGION scalar. Negation is likewise refused outright, so there is no empty-given inversion surprise to warn about.
  • Entry point only: not joined, but inherited through extend. The gate applies to the source a query enters through. A gate on a source reached only via join_* never fires, at any depth, so anything ungated that joins a locked base hands the base's rows to every caller. A source that extends a locked base and declares no gate of its own does carry the base's gate; declaring its own annotation replaces it. A source derived from a locked base via a query (source: z is locked -> { … }) instead always carries the base's gate in addition to its own: the derivation recurses into the base unconditionally, so an own gate does not replace it, and the two combine as separate AND'd entries. Pair a locked base with curated extension sources, using access modifiers (include { public: …, private: * }), so an extension re-exposes only a curated column surface, and keep sensitive sources out of ungated joins.
  • A derivation that drops a column the gate reads fails CLOSED. extend { except: org_id }, or an accept: that omits it, leaves the grafted filter unable to compile, so the request is denied rather than served ungated. The one hole to know: dropping the gated column and then rename:-ing a different column onto that exact name grafts successfully and binds the gate to the wrong column. Narrow, but real, so don't recycle a gated column's name. If a derivation's own projection needs to drop the column a row-level term would read, use a source-level term ('literal' in/= $GIVEN) instead, since it never depends on any projected column.
  • The quoted-string and file-level forms are refused at load and no longer exist. A quoted expression on the source: line, in either quote, a file-level ##(…) "<expr>" applying to every source in the file, and the earlier internal dimension: authorized is <expr> form are all retired; the load names the rewrite. Every .malloy file in a package compiles at load and any failure aborts the package, so a retired-form gate anywhere in the package is refused. Only a declaring file outside the package escapes that: it loads and denies every request instead, with no compile-time hint. See your deployment's reference documentation.
  • A gated source can be persisted, but the gating column freezes. storage= and #@ preaggregate refuse a gated source outright; a colocated #@ persist is admitted when the gate is provably the entry point's own row filter. The gate still runs live on every query, so rows come back filtered - but the column it filters ON is frozen at build time, so a row whose access decision changes keeps being served under the old decision until the next rebuild. Pair #@ persist on a gated source with a freshness window (fallback="live"), which is the only control that bounds that - and read skill:malloy-materialization for where that window binds, because on a standalone Publisher it does not.

Trust caveat. Givens are caller-asserted, anyone who can reach the query API can claim a favorable given, e.g. {"ROLE":"admin"}. Neither annotation is a real boundary unless it sits behind a trusted tier that sets givens from its own verified context, never directly from an untrusted caller. Neither is, on its own, end-user authentication.

Forward direction. Givens are how access control is built here, and the planned next step is identity-bound ("secure") givens - reserved values a trusted tier populates from a verified token or proxy header, which the caller cannot override - turning these gates into a standalone boundary. Model access on given: + these two annotations now; it is the surface that carries forward.

Full syntax, inheritance rules, validation, and the error contract are covered in your deployment's authorize reference documentation.

Join Syntax

  • Simple join: join_one: users with user_id
  • Expression join: join_one: origin is airports on origin_code = origin.code
  • Composite key: join_one: items on order_id = items.order_id and product_id = items.product_id
  • Multiple joins to same table: join_one: origin_airport is airports with origin

Join Types: join_one: (many-to-one, efficient) | join_many: (one-to-many, always safe) | join_cross: (many-to-many)

Verify cardinality before writing joins: run: target -> { group_by: fk_col, aggregate: n is count(), having: n > 1, limit: 5 }. 0 results → join_one. Any results → join_many.

After Writing: Check & Review

Check diagnostics after writing. Errors cascade, fix the FIRST error only, then re-check. If errors persist, use the debugging strategy: look at first error, search docs if unsure, fix, repeat.

Validate with execute_query: Run queries, check distributions, verify measures, confirm joins (no fan-out).

To inspect the sources and fields a model already defines, ground yourself with get_context. It returns the package's sources, views, and fields, so there is no separate schema-search step. When you're unsure of Malloy syntax, call search_malloy_docs rather than guessing.

Advanced Patterns

Load the relevant reference file when you encounter these scenarios:

ScenarioRead
Need pre-aggregated or windowed sourcereference/query-sources.md
Curating access modifiersreference/access-modifiers.md
Normalized/ER-style schema (4+ tables, no clear fact table)reference/normalized-schemas.md
Formalizing analysis into a modelreference/analysis-to-model.md
Many-to-many / bridge tables / composite keysreference/bridge-tables.md

Done

Step complete. Output: base source files (.malloy, one per table) and joined source files (.malloy, one per analytical domain).

Suggest next steps to the user, unless your host's instructions say it shows follow-up suggestions of its own:

  • Open the model to see it live. On a local Publisher server that is http://localhost:4000/<environmentName>/<packageName> for the package, or http://localhost:4000/<environmentName>/<packageName>/<modelPath> for a single model file. First confirm the running server actually serves this package (it is in the loaded publisher.config.json, or mounted live with --server_root . --watch-env <env>); a package the server has not loaded returns a 404, so do not hand over a link to a package that was just authored but never loaded.
  • Run analysis questions against the model (see skill:malloy-analysis).
  • When you're ready to serve the model, publishing is out of scope for open-source Publisher v1: self-hosters commit the package to git and use their host's publish path.

© 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

SKILL.md and 5 other files in skills/malloy-model of malloydata/publisher.

  • SKILL.md
  • reference/access-modifiers.md
  • reference/analysis-to-model.md
  • reference/bridge-tables.md
  • reference/normalized-schemas.md
  • reference/query-sources.md

Open the folder on GitHubat commit 39a546f

Compare with similar skills

Malloy Model 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 Model compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Malloy Model this skillmalloydata/publisher116—~6.9kAutomated safety check: PassMIT
Paper to Chinese Patent DrafterYuan1z0825/nature-skills46k1 repos~1.1kAutomated safety check: PassApache-2.0
C15tc15t/c15t1.9k1 repos~1.6kAutomated safety check: PassApache-2.0
Contract Reviewevolsb/claude-legal-skill4621 repos~3.6kAutomated safety check: PassMIT
Legal Clinic Client Intakeanthropics/claude-for-legal9.6k3 repos~3.2kAutomated safety check: PassApache-2.0
Paper To Cn Patentsnipp-zha/Paper-to-patent-Skill1051 repos~959Automated safety check: PassNone

Similar skills

  • Paper to Chinese Patent Drafter

    Yuan1z0825/nature-skills

    Drafts Chinese invention patent applications and technical disclosures from research papers or inventor materials, tying each claim feature to source evidence.

    46k GitHub starsUsed in 1 repo~1.1k tokens
    Legal & ComplianceAuto-check passed
  • C15t

    c15t/c15t

    Work with c15t consent management docs, APIs, and integrations for Next.js, React, and JavaScript.

    1.9k GitHub starsUsed in 1 repo~1.6k tokens
    Legal & ComplianceAuto-check passed
  • Contract Review

    evolsb/claude-legal-skill

    Review legal contracts, NDAs, employment agreements, SaaS terms, and M&A documents.

    462 GitHub starsUsed in 1 repo~3.6k tokens
    Legal & ComplianceAuto-check passed
  • Legal Clinic Client Intake

    anthropics/claude-for-legal

    Official

    Structures a legal clinic client intake interview and produces a case summary with cross-area issue spotting, conflict flags and triage classification.

    9.6k GitHub starsUsed in 3 repos~3.2k tokens
    Legal & ComplianceAuto-check passed
  • Paper To Cn Patent

    snipp-zha/Paper-to-patent-Skill

    Convert scientific papers, theses, technical reports, source code, figures, or research manuscripts into evidence-grounded Chinese invention patent drafts.

    105 GitHub starsUsed in 1 repo~959 tokens
    Legal & ComplianceAuto-check passed
  • Employment Contract Templates

    ynulihao/AgentSkillOS

    Create employment contracts, offer letters, and HR policy documents following legal best practices.

    617 GitHub starsUsed in 12 repos~4.1k tokens
    Legal & ComplianceAuto-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 Model

What does Malloy Model do?

Build Malloy semantic models with base source and joined source files. Malloy Model is an agent skill from malloydata/publisher. Build Malloy semantic models with base source and joined source files.

When should I use Malloy Model?

Malloy Model fits situations like: modifying .malloy files; user asks to create a malloy model; create a source; any Malloy model authoring task.

How do I install Malloy Model in Claude Code?

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

How do I install Malloy Model in Codex?

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

Can I use Malloy Model 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-model -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-model, .gemini/skills/malloy-model, .github/skills/malloy-model and .opencode/skills/malloy-model in your project.

What does Malloy Model need to run?

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

Does Malloy Model 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 Model 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 Model use?

Malloy Model 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 Model use?

About 6.9k tokens (SKILL.md is roughly 28k 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 Model?

Skills that share tags, products or a category with Malloy Model: Paper to Chinese Patent Drafter (Yuan1z0825/nature-skills, 46k stars), C15t (c15t/c15t, 1.9k stars), Contract Review (evolsb/claude-legal-skill, 462 stars) and Legal Clinic Client Intake (anthropics/claude-for-legal, 9.6k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Malloy Model?

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 8, 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.