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 browserREST API at
localhost:4000/api/...– Build custom applicationsPublisher 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-analyticsThat 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 inHome-relative paths:
~/packageAbsolute 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-packagesOption 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/healthList Environments
curl http://localhost:4000/api/v0/environmentsA 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
Open
http://localhost:4000Click your package
Click a model
Click Explore
Add a dimension and measure
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
First start: Publisher reads
publisher.config.jsonand syncs it topublisher.dbSubsequent starts: Publisher loads from the database, ignoring config file changes
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.jsonand want those changes appliedYou 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:
Explorer UI – Visual query builder
REST API – Build custom applications
Publisher SDK – Embed analytics in React apps
MCP for AI Agents – Connect AI assistants
Resources: