Malloy Documentation
search

Publisher serves Malloy models through APIs—for tools, applications, and AI agents.

Once published, your models can be accessed via:

  • Web UI at localhost:4000 – Browse and query models in your browser

  • REST API at localhost:4000/api/... – Build custom applications

  • Publisher SDK – Embed analytics in React apps

  • MCP at localhost:4040 – Connect Claude and other AI assistants


Quick Start

1. Create a Package

The fastest way is to let Publisher scaffold one:

npm create @malloy-publisher/malloy-package my-analytics

That writes the package and a starter model, registers it in publisher.config.json so the server actually serves it, and sets up the workspace around it: a start script, an MCP config, agent instructions, and the Malloy agent skills as files an AI assistant can read. Seed the starter model from a local file with --data (CSV, Parquet, or Excel):

npm create @malloy-publisher/malloy-package my-analytics -- --data ./orders.csv   # npm create needs the --
npx @malloy-publisher/create-malloy-package my-analytics --data ./orders.csv      # npx does not, and rejects it

The two entry points differ in one way that matters. npm create reads anything before the -- as one of its own options, so the separator is required there. npx passes flags through untouched, and a -- would reach the tool as an extra argument.

A package is just Malloy, so it is not limited to a local file: point its model at a database connection your config defines and the same workspace serves a warehouse. Both entry points require Node on your machine.

If you would rather assemble the package yourself, it is just a directory with your models and a manifest:

my-analytics/
├── publisher.json       # Package manifest
├── orders.malloy        # Your semantic model
└── data/               # Optional: local data files

Create publisher.json:

{
  "name": "my-analytics",
  "version": "1.0.0",
  "description": "Order analytics semantic models"
}

2. List It in a Config File

Publisher only serves packages that publisher.config.json lists, so create one in the parent directory (the folder containing my-analytics/):

{
  "frozenConfig": false,
  "environments": [
    {
      "name": "default",
      "packages": [
        { "name": "my-analytics", "location": "./my-analytics" }
      ]
    }
  ]
}

3. Start Publisher

From that same parent directory, run:

npx @malloy-publisher/server --server_root .

The --server_root should point to the directory that contains your package folder(s), not the package itself.

my-workspace/                ← Run npx from HERE (--server_root .)
├── publisher.config.json    ← Server configuration
└── my-analytics/            ← This is your package
    ├── publisher.json
    └── orders.malloy

Want to try it immediately? Use malloy-samples:

git clone https://github.com/credibledata/malloy-samples.git
cd malloy-samples
npx @malloy-publisher/server --port 4000 --server_root .

Then open http://localhost:4000 to explore the sample models.

For alternative deployment methods (Docker, build from source), see Deployment & Configuration below.

4. Open Browser

Go to http://localhost:4000 to browse and query your models. If the package list is empty, the config did not load: check the startup log rather than the status endpoint, which reports serving either way.

That's it for local files. If your models use DuckDB with .parquet, .csv, or .json files, no connection configuration is needed on top of the config above, because every package gets its own DuckDB sandbox automatically.

Note: Publisher copies your package when it loads it, so editing a .malloy file does not change what a running server serves. Restart with --init to re-copy from source and pick the edit up.

While you are iterating on a model, restart with --init --watch-env default instead: that mounts the package in place and recompiles it as you save. --watch-env needs --init here, because the in-place mount is only set up when Publisher has not already copied the package into publisher_data/. Adding --watch-env on its own to a server root you have already started does nothing, and does so quietly: /api/v0/watch-mode/status still reports "enabled": true while your edits are ignored.

Want to connect to a database? Add a connection to the same publisher.config.json:

{
  "environments": [
    {
      "name": "default",
      "connections": [
        {
          "name": "my_postgres",
          "type": "postgres",
          "postgresConnection": {
            "host": "localhost",
            "port": 5432,
            "databaseName": "analytics",
            "userName": "malloy",
            "password": "<password>"
          }
        }
      ],
      "packages": [
        {
          "name": "my-analytics",
          "location": "./my-analytics"
        }
      ]
    }
  ]
}

See Publisher Connections for BigQuery, Snowflake, and other databases. To change the visual style of rendered charts and tables, see Theming Publisher.


Package Locations

A package's location can be any of these:

Local Filesystem

  • Relative paths: ./package, ../package, resolved against the directory holding the config they appear in

  • Home-relative paths: ~/package

  • Absolute paths: /absolute/path/to/package

Packages do not have to live inside your server root. Keeping a config next to the packages it points at means the two move together.

GitHub

  • https://github.com/owner/repo/tree/branch/package-path

Google Cloud Storage

  • gs://bucket/path/to/package

Amazon S3

  • s3://bucket/path/to/package


Deployment & Configuration

Option 1: npx (Quickest)

npx @malloy-publisher/server --server_root ./my-packages

Option 2: Build from Source

git clone https://github.com/malloydata/publisher.git
cd publisher
bun install
bun run build:server-deploy
bun run start

Option 3: Docker

Mount your config and packages into the container:

docker run -p 4000:4000 -p 4040:4040 \
  -v ./publisher.config.json:/publisher/publisher.config.json:ro \
  -v ./packages:/publisher/my-packages \
  ms2data/malloy-publisher

Mount your packages anywhere except /publisher/packages: that path holds the server's own code inside the image, and mounting over it breaks the container. Point each package's location at wherever you mounted them (./my-packages/... above).

Publisher has no built-in authentication and binds all interfaces. Anyone who can reach the port can read your connection configuration (database passwords included), run SQL through any connection, and by default add connections of their own, with those changes persisted to publisher.db. The DuckDB sandbox each package gets automatically will read files off the server's filesystem, so this applies even with no database configured. Publish Publisher only on a trusted network, or put your own authentication in front of it. The MCP port (4040) is unauthenticated too.

Docker Compose

version: '3.8'

services:
  publisher:
    image: ms2data/malloy-publisher
    ports:
      - "4000:4000"
      - "4040:4040"  # MCP endpoint for AI agents
    volumes:
      - ./publisher.config.json:/publisher/publisher.config.json:ro
      - ./packages:/publisher/my-packages
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

Use /health for the container healthcheck, not /status and not /api/v0/status. A bare /status is not a route at all: the web app's catch-all answers it with 200 and an HTML page, so curl -f passes even when the API is not serving. /api/v0/status is a real endpoint, but its response includes your connection configuration, so a healthcheck pointed at it writes database passwords into the container's health log every interval. /health reports liveness and readiness and nothing else, with no configuration in it. Either way this checks that the server is up, not that your packages loaded.

Environment Variables

Publisher reads runtime settings from environment variables. The most common ones are below; see the configuration reference for the full list and matching CLI flags.

Env var Default Meaning
PUBLISHER_PORT 4000 REST + static-app HTTP port.
MCP_PORT 4040 MCP HTTP port (for AI agents).
SERVER_ROOT . (cwd) Directory containing publisher.config.json.
LOG_LEVEL debug One of error, warn, info, verbose, debug, silly.
GOOGLE_APPLICATION_CREDENTIALS unset Fallback path to a GCP service-account JSON for BigQuery connections that don't include inline auth. Ignored when the connection config provides its own credentials.

Verify It Works

Health Check

curl http://localhost:4000/health

List Environments

curl http://localhost:4000/api/v0/environments

A 200 from either endpoint means the server is up, but it does not mean your packages loaded: a config that fails to load leaves Publisher reporting "operationalState": "serving" with an empty environment list. If a package is missing, count what /api/v0/environments returns before assuming the server is fine, and check the startup log, where the cause is named: Failed to load package for one bad package, Error initializing environment when a whole environment is dropped, Failed to parse when publisher.config.json is not valid JSON, and Error reading publisher.config.json when the config parsed but could not be processed, which is usually a ${VAR} you have not set.

See the REST API documentation for all available endpoints.

Test in Browser

  1. Open http://localhost:4000

  2. Click your package

  3. Click a model

  4. Click Explore

  5. Add a dimension and measure

  6. Run query


State Persistence

Publisher persists configuration changes in a local DuckDB database (publisher.db). This means changes made via the REST API—adding environments, packages, or connections—survive server restarts.

How It Works

  1. First start: Publisher reads publisher.config.json and syncs it to publisher.db

  2. Subsequent starts: Publisher loads from the database, ignoring config file changes

  3. API changes: Adding/removing environments, packages, or connections updates the database

Reinitializing from Config

To reset the database and reload from publisher.config.json:

npx @malloy-publisher/server --init --server_root .

Use --init when:

  • After upgrading Publisher (schema may have changed)

  • You've updated publisher.config.json and want those changes applied

  • You want to reset to the original configuration

  • You're troubleshooting configuration issues

--init deletes publisher_data/ and rebuilds it from the config file, without prompting. Anything that only exists in the database or in Publisher's copy of a package is discarded, including environments, packages, and connections added through the API or the UI. Keep your source packages outside publisher_data/ (as the layout above does) and --init is safe to run.

Note: On first run, Publisher automatically creates the database and syncs from publisher.config.json—no --init needed.

Mutable vs Frozen Configuration

By default, Publisher allows configuration changes via the API, and that default is unauthenticated: any caller who can reach the port can add or change a connection, and the change survives restarts. Set frozenConfig: true on any Publisher reachable beyond your own machine. It is the control that closes config writes (they return 403), though it does not stop queries, so it is not a substitute for keeping the port off untrusted networks:

{
  "frozenConfig": true,
  "environments": [...]
}

When frozenConfig: true:

  • API endpoints that modify configuration return errors

  • The UI hides add/edit/delete controls

  • Only the config file (with --init) can change the setup

Storage Location

Publisher creates these files in your --server_root directory:

my-server-root/
├── publisher.config.json    # Initial configuration
├── publisher.db             # Persisted state (DuckDB)
├── publisher.db.wal         # Database write-ahead log
└── publisher_data/          # Downloaded packages (from GitHub, etc.)

Next Steps

Your models are published. Build consumption experiences:

Resources: