publisher
AGENTS.md132 starsChanged 8 days agoMITView on GitHub
Publisher is the analytics engine for Malloy, created and maintained by Credible. It serves one or more Malloy model packages over a REST API and a single MCP endpoint. If you are an AI agent working in this repo, here is what you can do with it and how to start. Operating model. Publisher is a microservice. It runs locally with the user's own permissions, or inside their cloud behind the gateway or application that authenticates their users; it does…
- Installs packages
- Sends data out
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository37 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- fix-scan-findingSkill
- publisher-releaseSkill
- eval-answerSkill
- eval-diagnoseSkill
- eval-importSkill
- eval-improveSkill
- eval-judgeSkill
- eval-loopSkill
- eval-reportSkill
- malloy-analysis-reportSkill
- malloy-analysisSkill
- malloy-chartsSkill
- malloy-classifySkill
- malloy-compliance-reviewSkill
- malloy-dashboardsSkill
- malloy-define-agentSkill
- malloy-defineSkill
- malloy-discoverSkill
- malloy-documentSkill
- malloy-getting-startedSkill
- malloy-gotchas-modelingSkill
- malloy-html-data-appsSkill
- malloy-lookml-reviewSkill
- malloy-materializationSkill
- malloy-model-as-you-goSkill
- malloy-modelingSkill
- malloy-modelSkill
- malloy-notebooksSkill
- malloy-phrase-detectionSkill
- malloy-powerbi-reviewSkill
- malloy-publishSkill
- malloy-queriesSkill
- malloy-reportsSkill
- malloy-reviewSkill
- malloy-run-agentSkill
- malloy-source-unreachableSkill
The file
We split this AGENTS.md into the rules it gives an agent
9 rules. About 0 are common, 3 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 14
Start a server (section 1) and poll GET http://localhost:4000/api/v0/status until operationalState is "serving".
- Very specific: line 15
Make sure your MCP client is connected to http://localhost:4040/mcp (section 2).
- Very specific: line 16
Call list_packages for the environment and package names, then get_context scoped to one package with your question (section 4).
- Very specific: line 17
Run execute_query with the names it returned, verbatim.
- Distinctive: line 33
All of it runs against a local server you start in step 1 and reach over MCP in step 2, or over REST when you work unattended (section 7).
- Very specific: line 115
Keep the @latest; without it npm may reuse a cached, older scaffolder.
- Distinctive: line 221
Ask "what can I explore here?" From a cold start, knowing no names at all:
- Very specific: line 241
If a reload appears to succeed but your edit never takes effect, check that the package name is in the path: …/packages/<pkg>?reload=true …
- Distinctive: line 251
Only the first environment in the watch list auto-reloads.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
<!--
Copyright (c) Credible Data Inc.
SPDX-License-Identifier: MIT
-->Working with Malloy Publisher
Publisher is the analytics engine for Malloy, created and maintained by Credible. It serves one or more Malloy model packages over a REST API and a single MCP endpoint. If you are an AI agent working in this repo, here is what you can do with it and how to start.
Operating model. Publisher is a microservice. It runs locally with the user's own permissions, or inside their cloud behind the gateway or application that authenticates their users; it does not authenticate anyone itself. Its access controls (#(authorize), #(access_filter), #(secure) givens, #(carries), the access record) are evaluated against what each request carries, so they enforce a policy only when that gateway authenticates the user, sets the givens from the verified identity, and is the only way to reach Publisher. When you model access controls, say so; never describe them as protecting a directly exposed server. See the operating model.
The fast path. Everything below is detail on these four steps:
- Start a server (section 1) and poll
GET http://localhost:4000/api/v0/statusuntiloperationalStateis"serving". - Make sure your MCP client is connected to
http://localhost:4040/mcp(section 2). If you started the server yourself in this session, your Malloy tools will not appear until the user reconnects the client; unattended, use REST (section 7). - Call
list_packagesfor the environment and package names, thenget_contextscoped to one package with your question (section 4). - Run
execute_querywith the names it returned, verbatim.
What you can do
- Discover what data exists: environments, packages, models, sources, and fields, without knowing any names in advance.
- Answer plain-English questions by running Malloy queries, which Publisher compiles to SQL and runs against the connected database.
- Build and change Malloy models: validate an edit with
compile_model, save it, thenreload_packageto run it by name. Themalloy-modelingskill covers the workflow. - Build a data app: a hand-authored HTML page in a package's
public/directory, backed by that package's models and served by Publisher with no build step. Themalloy-html-data-appsskill covers it. - Review Malloy for correctness with the
malloy-reviewskill. - Measure whether an agent can actually answer questions against a model, with the
eval-loopskill: it scores answers against verified goldens, reports which entities retrieval delivered, diagnoses each failure and who owns it, and builds a servable report.examples/storefront/evals/storefront-touris a worked example over the bundledstorefrontpackage: twelve questions and an answer key, each golden derived twice. Its README has the exact commands. The set is the questions and the key; running it, and whatever the run produces, is yours. - Build a report: a
.malloyfile with an## artifacttag is a report, a page of query and text tiles behind filter controls with drill click-through, no code and no build step. Itslayout=is the one choice:gridfor a dashboard scanned at a glance,linearfor a story read top to bottom. A report can live anywhere in the package; write a new one at the package root unless asked otherwise, and point# drill { to="path/without-extension" }at another report by its path. People who want the classic drag-and-drop feel can build the same file in the Console instead — drag a tile to move it, drag any edge or corner to resize it — and the two are interchangeable, because the builder splices the file rather than owning a layout document of its own. Themalloy-reportsskill covers it; docs/reports.md is the reference, and docs/choosing-a-surface.md says when to pick a report, which layout, or a data app. - Govern access: givens declare runtime parameters that drive filter widgets, row-level access decides which rows a caller sees,
#(authorize)decides whether a caller may query a source at all, and discovery curation — a package'sindex.malloy— decides what is visible, and queryable, in the first place.#(classification)says what a field is, in the policy's words, soget_contextreports the classes of every field and the package warns about any it leaves unclassified;#(carries)on a source says what it may expose and a leak refuses the load; and the single-package read summarizes, for each published entry point, what it may expose and its fields by class. Every query writes one access record. The bundledgoverned-analyticspackage shows all four controls together. To check a package against a written access policy on every change, use themalloy-compliance-reviewskill, withmalloy-classifyto classify its fields first; docs/compliance-review.md has the CI recipe, and the bundledclinical-recordspackage is a worked HIPAA example. - Materialize for cost and speed: one
#@ persistannotation turns an expensive source into a table, rebuilt on demand, from themalloy-pubCLI, or on a cron with the opt-in scheduler;#@ preaggregaterolls a measure up to a coarse grain. Themalloy-materializationskill covers it; docs/materialization.md and docs/preaggregation.md are the references. - Model a warehouse from scratch: add a connection (BigQuery, Snowflake, Postgres, MySQL, SQL Server, Trino, Databricks, MotherDuck, DuckLake, and more), then
search_database_schemaranks its tables against a plain-English description and hands back thesource:line for each. DuckDB is built in for CSV, Parquet, JSON, and Excel files. - Browse it all in the Publisher Console, the built-in web UI on the REST port (docs/console.md), where charts and dashboards defined in the model render.
All of it runs against a local server you start in step 1 and reach over MCP in step 2, or over REST when you work unattended (section 7).
1. Start the server first
The MCP tools talk to a running server, so nothing works until it is up.
Requirements. Node.js 20 or newer for npx and for a clone. Building from a clone also needs Bun 1.3.13 or newer. The Docker image carries its own runtime and needs neither. The bundled example packages are all DuckDB-backed, so no database credentials are needed for anything in this file.
Package names. Everything here is scoped: the server is @malloy-publisher/server, the scaffolder @malloy-publisher/create-malloy-package, and the language itself @malloydata/malloy. The unscoped malloy on npm is an unrelated logging library, so npm install malloy installs nothing you want.
The fastest way, with nothing cloned and no Bun installed:
npx @malloy-publisher/server@latest --port 4000 # REST on :4000, MCP on :4040From a clone of this repo:
bun install
bun run build && bun run start # same portsIn Docker, with a publisher.config.json in the current directory (copy packages/server/publisher.config.example.duckdb.json to serve the same example packages):
docker run -d -p 4000:4000 -p 4040:4040 \
-v $(pwd)/publisher.config.json:/publisher/publisher.config.json:ro \
ms2data/malloy-publisherOnce it is serving, the Publisher Console is at http://localhost:4000 and the MCP endpoint is at http://localhost:4040/mcp. The REST port is --port (env PUBLISHER_PORT, default 4000) and the MCP port is --mcp_port (env MCP_PORT, default 4040). The REST port binds 0.0.0.0 by default; the MCP port binds 127.0.0.1 (env MCP_HOST, falling back to PUBLISHER_HOST). Pass --host 127.0.0.1 to keep both loopback-only, or set MCP_HOST=0.0.0.0 to expose MCP on the network. The server is stateless and unauthenticated on both ports and can read any data the models connect to, so keep it on localhost, or put an authenticating gateway in front before exposing it further (docs/security-posture.md lists what it defends against and what it leaves to the gateway). docs/deployment.md covers npx, Docker, and Compose in full.
To re-initialize the sample storage on a later run, build first and then start with --init: bun run build && bun run start:init. Start one npx server at a time: concurrent first runs can race in the shared npx cache and corrupt the install (docs/deployment.md has the recovery step).
Keep the @latest. npx resolves through a shared cache and will happily re-run a build it downloaded weeks ago, so a bare npx @malloy-publisher/server can serve an old version while looking like a fresh start. To see which build answered, read version from GET /api/v0/status (or get_status); a fixed bug that appears to still be there is the usual first sign of a stale one.
On startup the server creates a .mcp.json in the directory it was run in, naming the MCP port it bound, which is why a session started in that directory finds the Malloy tools with no registration step. It does not always create one, so do not promise a user the file exists without checking: it skips an existing file, git working trees, the home directory, and a few other cases (the full list). Read the startup log rather than assuming, and ls -a if you need certainty. Whenever it skips, it prints the claude mcp add command that connects an agent anyway; use it as printed, because it is deliberately local scope and -s user would be shadowed by the very file that caused the message. The file also outlives the server and is never corrected, so a stale one does not merely fail: another process may hold that port and answer from the wrong data. Comparing URLs does not settle that, since two Publishers on one port give the same URL; call list_packages, which names what you are actually talking to. Never delete a .mcp.json you did not create. --no-mcp-config turns it off, and the Docker image sets PUBLISHER_NO_MCP_CONFIG=1.
Poll until the server reports serving rather than assuming a fixed wait. From a clone the sample packages are read straight from examples/, so this is usually seconds. A first npx run has to download the published package and then fetch the samples from GitHub, which is network-bound and can push it to a minute or two:
curl -s http://localhost:4000/api/v0/status | jq .operationalState # -> "serving"The server also prints one PUBLISHER_READY line to stderr at the moment it reaches serving,
carrying environment, package, and load-error counts, so a script can watch for that line instead
of polling; if initialization fails, PUBLISHER_INIT_FAILED is printed in its place. A first-run
download reports its clone progress on stderr too. Publisher needs Node.js 20 or newer: on anything
older it prints PUBLISHER_UNSUPPORTED_NODE required=>=20 detected=<version> and exits non-zero
without binding a port, so if you are waiting on PUBLISHER_READY treat that line as terminal rather
than continuing to poll. The only fix is to run Publisher on a newer Node. If you are changing the
runtime yourself, switch it and then start the server as a separate command: mise use -g node@20 &&
npm start chained in one shell still runs the old Node, because PATH is not re-evaluated mid-chain,
and the second failure looks identical to the first.
The full startup-signal contract is in docs/configuration.md.
serving does not mean everything loaded. A package that fails to load is skipped, not fatal, so the
server serves whatever did load and the package is simply absent. If data you expect is missing, check
curl -s http://localhost:4000/api/v0/status | jq .loadErrors, which is absent when everything loaded
and otherwise names each environment or package that did not load, and why.
The same array reports the other failure, the one you cannot see by looking at what is present: an
entry with stale: true is a package that IS listed and IS answering queries, but whose most recent
reload failed to compile, so it answers from the model compiled before that save rather than from the
files on disk. Nothing else says so. The package's entry under environments, and its own package
resource, both read as serving, so join on the package name to tell a current package from a stale
one. Fix the model and reload to clear it.
Starting from your own data
To serve the user's data rather than the bundled examples, scaffold a package — in a fresh directory, because the scaffolder writes a workspace (start/reset scripts, an MCP config, agent instructions, the skills) into the current directory as well as the package:
mkdir my-data && cd my-data
npm create @malloy-publisher/malloy-package@latest sales -- --data ./orders.csv
npm start- Keep the
@latest; without it npm may reuse a cached, older scaffolder. - The
--before--datais required, ornpm createswallows the flag and the scaffolder stops. --datatakes CSV, Parquet, JSON, newline-delimited JSON, or Excel.xlsx, by a path relative to the current directory; the file is copied into the package. Omit it for a small sample dataset.- A seeded package starts as a row count and an overview — the modelling is yours to do next.
- With the workspace in place,
npm start(and a barenpx @malloy-publisher/serverfrom that directory) serves this package, not the examples.npm startserves it in watch mode, so a saved edit to the model recompiles without a reload (section 6 explains watch mode and its limits). - The workspace also writes a
.mcp.json, so an agent session started in that directory finds the Malloy tools with no registration step (section 2).
Details — caching, workspace layout, the bare npx form — are in docs/scaffolding.md.
Connecting a database. A package is just Malloy, so it is not limited to local files. Add a
connection to the package, point the model at it, and the same workspace serves a
warehouse. If the warehouse exists but no model does yet, search_database_schema ranks its
tables against a plain-English description and returns the source: line to start each one from.
Ranking works with no API key; the optional embedding-backed mode is in
docs/configuration.md.
2. Connect your agent
Publisher exposes one MCP endpoint: http://localhost:4040/mcp (streamable HTTP, stateless, unauthenticated; put it behind a gateway if you expose it beyond localhost).
Connect the client after the server is up. An MCP client discovers a server's tools when it connects, so if the client was already running when you started the server (for example you asked the agent to start it), its Malloy tools stay missing until it reconnects. In Claude Code: run /mcp, select malloy, then choose Reconnect. That panel reports Auth: not authenticated and offers Authenticate as its first option, which does not apply here because the endpoint has no auth; Reconnect is the one that works. Restarting Claude also works. The simplest path is to start the server first, then launch the agent.
If you are the agent and you started the server during this session, your Malloy tools will not show up however long you wait: your tool list was fixed when you connected. You cannot reconnect yourself. When a user is present, say so and ask them to run /mcp, select malloy, and choose Reconnect (the panel offers Authenticate first, which is not it), or to restart Claude. Do not quietly fall back to calling the REST API with curl instead: it hides a fixable problem the user can clear in seconds, and it gives up the grounded discovery, compile checks, and reload that the tools exist to provide. Running unattended, with nobody to reconnect you, is the other case: there the REST API is the supported interface, not a workaround. See section 7.
There is a third case, and it is the one that costs the most time because it looks exactly like the first: a project's .mcp.json is only discovered from the directory the agent session started in. A session launched from a parent directory, or from anywhere else, never sees the server, however long it waits and however many times it reconnects. Reconnecting cannot fix it, because the server was never in the client's list to reconnect to.
Skills are the near miss here, and they behave differently: .claude/skills/ is rescanned as the working directory changes, so a session started further up picks them up on its own once work moves into this directory. That asymmetry is worth knowing precisely because the two symptoms look identical from the outside: same "the agent has nothing", different cause, different fix.
Tell them apart by what is missing:
| Symptom | Cause | Fix |
malloy is listed in /mcp but disconnected | client connected before the server existed | Reconnect (or relaunch the agent) |
malloy is not listed in /mcp at all, and .mcp.json is here | session started outside this directory | relaunch the agent from here. A session that starts here reads this file, so a user-scoped entry would be shadowed by it; user scope is for sessions started elsewhere |
malloy is not listed in /mcp at all, and there is no .mcp.json here | the server skipped writing one, or the write failed; its startup log says which | run the claude mcp add line the server printed, from the directory you start the agent in. Relaunching alone cannot help when there is no config to find, and note a config may exist at the repository root instead of here |
| tools present, no skills auto-invoked | session started outside this directory, the same cause as the second row | relaunch the agent from here; skills are rescanned as the working directory changes |
The registration that stops the directory mattering, because it is stored per user rather than per project. Use the port this server actually bound, which its startup log prints; 4040 below is only the default:
claude mcp add --transport http malloy http://127.0.0.1:4040/mcp -s userThis one is persistent and global: it outlives the session and the server. Undo it with claude mcp remove malloy -s user. Prefer the command the server printed, at its default local scope, unless the point is specifically to stop the directory mattering.
Note the two symptoms of that one cause do not share a fix: the MCP server list is fixed at session boot, so it needs either a relaunch or the user-scoped registration above, while skills need the session actually rooted in the directory. There is no user-scope equivalent for project skills short of symlinking them into ~/.claude/skills/.
Claude Code: this repo ships a project .mcp.json, so from a clone Claude Code offers to connect on first run. Approve it once. To add it elsewhere:
claude mcp add --transport http malloy http://localhost:4040/mcpCursor: add to .cursor/mcp.json or global settings:
{ "mcpServers": { "malloy": { "url": "http://localhost:4040/mcp" } } }Codex: add to ~/.codex/config.toml:
[mcp_servers.malloy]
url = "http://localhost:4040/mcp"stdio-only clients (older Claude Desktop) bridge through mcp-remote:
{
"mcpServers": {
"malloy": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4040/mcp", "--allow-http"]
}
}
}The trust gate
Claude Code asks, once per directory, whether to trust the folder — and this is a second gate,
separate from connecting the server. In a workspace nobody has trusted yet it lists the Malloy
tools and then refuses every call, and a .claude/settings.json allowlist is discarded rather than
merged. Start Claude Code interactively in the directory once and answer the prompt; a headless run
is never asked, so it cannot clear the gate. You know it cleared when a query returns data.
3. The MCP tools
get_context: discovery and grounding, and the tool to reach for. Describe what the question needs as typedsearch_targets({target_type, search_text}, where target_type issource,dimension,measure,view,joinordimensional_value) and name the package inscopes. One call usually answers: the response is asourcesarray, best first, with the entities that matched nested inside each source's card, so there is no drill-down step. Send one target per concept, not a list in onesearch_text. When a field you need is missing, a target is inunresolved_targets, or a warning says results were cut, call again withscopes[].sourcenaming that source and one target per missing concept. Omit a target'ssearch_textto enumerate that type instead of ranking it; asourcetarget with no text is the catalog browse, which pages withoffset/next_offsetand returns thin cards (a one-line summary rather than the full doc). A search takes exactly onescopesentry naming an environment and a package, because retrieval is indexed per package. With nosearch_targets,get_contextlists instead: no arguments lists the environments,environmentlists that environment's packages, a package inscopeslists its sources (withpackage_info, the package's description), and a source inscopeslists its fields. A level with one choice is entered, so a server with one package answers a no-argument call with its sources. Each card'ssource_info.resource_idholdsenvironment,package,model_pathandsource, whichexecute_querytakes asenvironmentName,packageName,modelPathandsourceName.source_infoalso carries that source's doc, its complete join list (empty means it declares none, so write the relationship inline instead of probing for one),givens,filter_params, andauthorize— the gate expressions in force with the givens each reads, so a source withauthorizedenies the query unless you supply them. A field reached through a join is its own entity named by its full dotted path, withjoin_pathnaming the traversal andrelationshipsaying whether aggregating through it fans out. Use the names it returns verbatim.list_packages: what this server serves — every environment and the packages in each, the same names a no-argumentget_contextlists. It is also the only place a package that FAILED to load appears; a plain listing just omits it, which reads as "does not exist" rather than "is broken".execute_query: run a Malloy query (a named view or query, or ad-hoc code) against a model and get JSON back.compile_model: compile-check Malloy source and get structured diagnostics back (severity, message, model, line and column) without running a query. Use it to validate a model or a change while authoring, instead of firing a throwaway query. Pick thescopethat matches the change:append(default) for a NEW definition,filefor an EDIT (the source compiles as the file, at true coordinates),packagefor a dry-run of every file as saved — including a what-if edit importers must survive.appendtext is checked against the model's published surface, so it may not declare its own data roots (import,connection.table(...),connection.sql(...)), declare agiven:(reading the model's own givens as$NAMEis fine), or set a##!compiler flag; validate a model that declares those atfileorpackagescope.appendtext that carries a model-level## artifacttag is a document, which other viewers run, so it is also refused for# image,# link, markup in a# label,@env.in an annotation and the other render-tag checks (docs/reports.md); plainappendtext keeps those tags.appendtext that carries a model-level## artifacttag is also read as a report, and the response gains adocumentcarrying itslayout(see docs/reports.md).reload_package: recompile a package from its on-disk model files so a source or view you added or changed after boot becomes queryable by name, without restarting the server. Use it to close the edit-and-run loop: validate withcompile_model, save,reload_package, thenexecute_querythe new view.get_status: the server's health. Its operational state, each environment's loaded packages, and every configured package that failed to load or is serving a stale model. This is the only MCP surface where a load failure is visible on its own, so call it before concluding a package is empty or missing, and after a model edit whose reload you did not run yourself.search_malloy_docs: search the Malloy language documentation when you need syntax.search_database_schema: discover what tables a database connection holds, and find the right ones for a plain-English description. Use it when building a model from a database rather than exploring one that already exists. Each table it returns carries thesource:line to start from. It returns names and types only: no row value is returned.get_skill: fetch a task guide, or list the guides in force. With ascopesentry it includes the guides the package ships in its ownskills/directory, and a package guide of the same name replaces the bundled one for that package (originsays which you are reading).get_agent: list the agents a package declares inpublisher.json, or fetch one agent's resolved definition (instructions, its own skills, declared schedules and budget, and thesourceContentShaanddefinitionShait came from). Adopt an agent only when the user names it: listing is not a reason to apply one, and nothing inget_contextadvertises them. A definition adds a brief and skills to your session and grants no tool or permission; its schedules are declared, not run. Themalloy-run-agentskill is the recipe (malloy-define-agentcovers writing a definition), andGET …/packages/{pkg}/agents/{name}is the REST form.
4. A first run, end to end
Ask "what can I explore here?" From a cold start, knowing no names at all:
list_packages, then pick an environment (the bundled one isexamples) and a package (the bundled packages arestorefront,governed-analyticsandhtml-data-app, plusclinical-recordswhen serving from a clone).get_contextwith a target per concept your question needs and that environment and package inscopes, to get the source, view, and field names. This call returns each matching source with its own matched fields nested in it, so there is no drill-down step; if a needed field is still missing, search again scoped to its source.execute_querywith those names, to get the answer. Charts and dashboards defined in the model render in the UI at http://localhost:4000.
Step 1 is only for a cold start. Once you know the environment and package, whether from your system prompt, a previous call, or the user, go straight to step 2.
5. Skills
The skills/ directory holds task-specific guides. They are symlinked into .claude/skills/, so Claude Code auto-discovers them, and other hosts can pull the same content as MCP prompts from the endpoint above. Start with malloy-getting-started. Use malloy-modeling to build or change a model, malloy-analysis to explore and answer questions, and malloy-review to check Malloy for correctness. Most of these are shared, open-source Malloy skills that Credible also serves, from the published @malloy-publisher/skills package; skills/README.md explains what is shared, why credible-* skills never appear here, and how the bare tool names in shared skills line up with the ones this server registers.
6. Iterating on a model (watch mode)
For your own fast checks while authoring, use compile_model; it validates a change and returns diagnostics with no server restart. When you save a model edit and want its new sources and views queryable by name, call reload_package (or GET /api/v0/environments/<env>/packages/<pkg>?reload=true), which recompiles just that package from disk in place, with no restart. Publisher compiles each configured package at boot and serves that cached model, so a source you add afterwards is not resolvable by name until the package is reloaded. That closes the edit-and-run loop without watch mode.
A reload that fails to compile is safe: your files are left alone, the previously compiled model keeps serving, and the compile errors come back in the response.
Reach for compile_model on the part you are unsure of, not the whole file. Both tools return the same diagnostics, and a failed reload is non-destructive, so once you are editing a saved file, write-then-reload is as safe as compiling first and one call cheaper. What compile_model uniquely buys you is checking something that is not on disk yet — an unfamiliar bit of syntax, a view body you are drafting — so send that fragment on its own. It has to stand alone as top-level Malloy, though: a bare view: / dimension: / measure:, or anything that only parses as a continuation of the model's last statement, is refused with a 400 rather than compiled. Wrap a view body in a top-level query:, or a field in a throwaway source: check is <source> extend { … }. Pasting a whole redefined source into it is the expensive way to learn what a reload would have told you, and a source the model already declares reports "Cannot redefine" rather than checking anything.
If a reload appears to succeed but your edit never takes effect, check that the package name is in the path: …/packages/<pkg>?reload=true reloads, …/packages?reload=true is the collection and cannot (it now answers 400; older servers answered 200 with the package list, which reads as success).
Spell the value true or false and nothing else. ?reload=1, ?reload=yes, ?reload=TRUE and a repeated ?reload=true&reload=1 now answer 400 quoting the value back; older servers read every one of them as false and answered 200 without recompiling. The same rule holds for the other boolean query params, dropTables and bypass_filters.
Both tools read the copy under publisher_data/<env>/<pkg>/, which sits in the server root: the directory the server was launched from, unless --server_root set another. For an env that is not in watch mode, that is a copy Publisher made of the configured source, so editing the original source directory does nothing until you re-copy it. To tell which mode you are in, GET /api/v0/watch-mode/status reports whether watching is enabled and for which environment; a watch-mounted package also shows as a symlink in publisher_data/. Editing the publisher_data/ copy is fine for a quick iteration, but keep the source of truth outside it: nothing there is version-controlled and --init wipes the whole tree. Watch mode mounts your own source directory in place, which is the durable way to iterate.
Watch mode is a separate, optional thing for a human: it is how someone launches the server so that they and any open browser tab see model edits live. It is a launch-time choice for whoever starts the server, not something to turn on by restarting a server that is already running. To use it, start the server with --watch-env <env> (or PUBLISHER_WATCH=<env>), which names an environment whose packages Publisher mounts in place (as symlinks) and watches, so edits to the source recompile. Requirements:
- The environment's packages must be LOCAL directories, not
github,gcs, ors3URLs. From a clone, the bundledexamplesenv is local and therefore watch-eligible. Undernpxit is not: the published server has no repo to read, so its bundled default fetches the same packages from GitHub. - The in-place mount is set up when the environment is first loaded from config: the first boot on a fresh server root (empty
publisher_data/storage), or any boot with--init. If you previously started the env WITHOUT--watch-env, its packages were copied intopublisher_data/and edits to your source do nothing; run once with both flags together,--watch-env <env> --init, to re-mount them in place (--initalone re-copies, it does not symlink). You do NOT need--initon every boot: once a package is mounted as a symlink it stays one, and later boots keep watching. - Only the first environment in the watch list auto-reloads.
A save that fails to compile is skipped: the package keeps serving the model it compiled last, so a change that does not appear is the symptom, and confident numbers from the previous model are the trap. The server does not push that at you, but it does record it: get_status (or GET /api/v0/status) reports the package as a loadErrors entry with stale: true and the compile error, get_context marks it in the package listing and attaches a note to anything it returns for that package, and both clear on the next save that compiles. Compile-check with compile_model first anyway; it is faster feedback than noticing afterwards.
7. Working unattended: the REST API
If you started the server yourself and there is no user to reconnect your MCP client (a one-shot task, a cloud sandbox), MCP is out of reach for the whole session. Use the REST API on port 4000 instead; discovery, query, compile, reload and schema discovery are all there. For schema discovery the endpoints are GET /api/v0/environments/{env}/connections, then .../connections/{conn}/schemas, then .../schemas/{schema}/tables, with a .../packages/{pkg}/connections/... form for the per-package duckdb sandbox; you rank the table list yourself, since that ranking is MCP-only. (search_malloy_docs and get_context's plain-English ranking stay MCP-only; for syntax, read the bundled skills/ markdown). Like MCP it is unauthenticated, so keep it on localhost. The playbook with worked examples is docs/ai-agents.md, and the running server serves its complete OpenAPI spec at http://localhost:4000/api-doc.yaml.
A complete query against the bundled storefront package, to copy from:
curl -s -X POST \
http://localhost:4000/api/v0/environments/examples/packages/storefront/models/storefront.malloy/query \
-H 'content-type: application/json' \
-d '{"query":"run: order_items -> by_category","compactJson":true}' | jq -r .resultThe same query from Python, with only the standard library. There is no Python SDK on PyPI to install: packages/python-client is generated from the OpenAPI spec but not published, and an unrelated malloy-publisher-client there is a third-party project.
import json
import urllib.error
import urllib.request
URL = ("http://localhost:4000/api/v0/environments/examples/packages/storefront"
"/models/storefront.malloy/query")
def query(malloy: str) -> list[dict]:
body = json.dumps({"query": malloy, "compactJson": True}).encode()
req = urllib.request.Request(
URL, data=body, headers={"content-type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.load(resp)
except urllib.error.HTTPError as err:
# The body carries the Malloy diagnostics; print it, don't swallow it.
raise SystemExit(f"{err.code}: {err.read().decode()}")
# `result` is a JSON string, not an object: parse it a second time.
return json.loads(payload["result"])
for row in query("run: order_items -> by_category"):
print(row)The map:
GET /api/v0/status: poll untiloperationalStateis"serving", then checkloadErrors(absent when everything loaded, and the REST equivalent ofget_status). Re-checkloadErrorsafter every edit-and-reload: an entry withstale: truenames a package that is still answering, from the model it compiled before your last save. AnemptyReasonmeans the server found no config at startup (or the--configpath was missing) and is serving nothing; it names the path it checked.versionis the server release.GET /api/v0/environments: the environment names every other path needs (the bundled one isexamples).GET /api/v0/environments/{env}/packages, then…/packages/{pkg}/models: what exists.GET …/models/{path}: the discovery step. The response'ssources(each with itsviews),queries, andgivensare the names you can run. Use them verbatim; never guess.POST …/models/{path}/query: run a query. The body is either{"query": "run: …"}(ad-hoc) or{"queryName": "…", "sourceName": "…"}(a named view;queryNamealone runs a model-level named query). Add"compactJson": trueand parse theresultstring to get plain row objects.POST …/models/{path}/compile: body{"source": "…", "scope": "append" | "file" | "package"}; structured diagnostics without running anything."append"(default) validates NEW definitions against the model (text carrying a model-level## artifacttag is a document and is also refused for render tags that make a value a URL or markup,@env.and the like);"file"compiles the source AS the file, which is how to validate an edit (append collides with "Cannot redefine");"package"(source optional) runs reload's worker compiler over every.malloyand.malloynbfile without touching the served model, plus the render-tag and dashboard checks a reload runs after it (not the package's other warnings: storage, persist, materialization, explores, surface and discovery warnings, among others). Its diagnostics may name files hidden from discovery; a replacement path that does not exactly match a file is warned and treated as new.GET …/packages/{pkg}/reports, then…/reports/{path}: a package's reports (every.malloyfile with an## artifacttag, wherever it lives, plus legacy.malloynb), each with itspathandlayout(gridorlinear), and one report's manifest. Thepathis a report's identity; two reports may share a basename in different directories. Every tile carries akind:query(it has aquery) ortext(it hasmarkdown, aname, and noquery). There is no run endpoint for a report: run its queries through…/models/{path}/querywithgivens, the same governed path every other query takes, and skip thekindtexttiles, which have nothing to run. The two query fields take DIFFERENT request shapes and are not interchangeable: a single-query report's manifestqueryis a NAME, so send{"queryName": "<it>"}; a tile'squeryis an expression, so send{"query": "run: <it>"}. Use the report'spathas the model in both. The older…/dashboardsand…/notebooksroutes are deprecated aliases for the same reports; do not build on them.PUT …/models/{path}: body{"source": "…", "expectedHash": "<sha256 of the text you read>"}; write a report into the package and reload it in place. OmitexpectedHashto create a report at any.malloypath that does not exist yet (put it at the package root unless asked otherwise; missing directories are created, never underpublic/, never at the rootindex.malloy), which answers 201 and refuses with 409 if the file already exists. WithexpectedHashit replaces only a file the package already serves as an editable report, so it never overwrites a model, an untagged file, a.malloynborindex.malloy; a file that changed since you read it answers 409 and nothing is merged. The text must carry an## artifacttag, always withlayout=; it is compiled first and refused with its problems when it does not compile, and the previous text is restored if the reload does not take it. 403 underfrozenConfig. docs/reports.md has the full rule.GET …/packages/{pkg}?reload=true: recompile a package after editing its files (the REST form ofreload_package).POST /api/v0/environments/{env}/packageswith{"name": "…", "location": "/absolute/path"}: publish a package of your own on a running server. By default it is installed in place, replacing the previous publish (a warning is logged). WithPUBLISHER_PACKAGE_VERSIONING=onit is published as the immutable version itspublisher.jsonversionnames, a missing or non-semverversionis a 400, and edits ship as a new version, not a reload; every package route takesversionId, and omitted meanslatest. docs/packages.md is the package format; docs/package-versions.md covers versions.
Reading this file without a clone? Every doc referenced here resolves at https://raw.githubusercontent.com/malloydata/publisher/main/<path>, for example docs/ai-agents.md.
8. Going deeper
docs/is the reference hub, see its index. Start with docs/ai-agents.md for per-client MCP config and the MCP tool reference. Then, by task: docs/packages.md for the package format (publisher.json, models, data); docs/connections.md for databases; docs/reports.md, docs/html-data-apps.md, and docs/choosing-a-surface.md for surfaces; docs/givens.md, docs/row-level-access.md, docs/authorize.md, and docs/discovery-and-access.md for governance; docs/materialization.md and docs/preaggregation.md for cost and speed; docs/configuration.md for every flag and env var; docs/deployment.md for Docker and Compose; docs/architecture.md and docs/api-overview.md for how it fits together.examples/holds the four served packages:storefront(ecommerce model + reports),governed-analytics(givens, authorize, row-level access),html-data-app(a no-build HTML dashboard), andclinical-records(a HIPAA policy, classified fields, analytical domains per role, and a compliance review).data-appis a standalone React SDK app, not a served package.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

