Agents write good reports and dashboards. They also write good code. I put Starlark on the MCP surface of mcp-data-platform so the same agent can write the code that updates the dashboard it just built, and so that code runs on a schedule after the agent is gone. The platform stores the program, versions it, and executes it as script:<name>. The program queries the warehouse, or invokes an API, writes a portal asset, and leaves a document the audience can hold without holding either.
This is the sixth post in MCP by Design. The earlier notes covered Go, composition, steering, knowledge, and testing. This one is why managed scripts are on that surface. It is grounded in the open-source
txn2/mcp-data-platform, also available hosted as Plexara.
§The Audience Does Not Need the Warehouse
A data analyst and a marketing manager are the people who want the report. They are not the people who need, or want, access to the warehouse or to the raw API behind it. That is the default, not a special case. The VP who bookmarks the weekly numbers, the partner who gets a PDF, same thing: they want the document. A public-facing dashboard goes further. Those readers should not have access to anything behind the page. If the report is public, a view-time query is a standing attack surface. A document produced at a fire time, with the slice already in it, has no warehouse behind it and no API behind it. There is not a more locked-down report than that.
The usual ways of shipping a living dashboard are expensive in different currencies. Tableau, Looker, Power BI: a specialist tool, often a specialist hire, then row-level security, workspace permissions, extract refreshes, and a per-seat bill that grows with the audience. A custom endpoint: a developer writes a handler, hangs auth on it, projects the columns, caches it, and owns it as the schema moves. Apache Superset is the right tool when the user is exploring and the query is the product. This is the other product. The question is solved. The audience does not want a query. The numbers still have to move.
§A Snapshot Goes Stale
The analyst asks, the agent builds the page, the query is right, the document lands in the portal. Tomorrow morning that page is yesterday. Most reports, after that first design, do not need another conversation. They need the same query against today’s data, written back into the same document. That is what the managed script is for. One (script, output name) pair is one portal asset, and every run adds a version of it.
You can fake the cadence by scheduling Claude Desktop, or any other host, to run the prompt again every morning. It will sort of work, at token cost, with a different path through the same tables on Tuesday than on Monday, and with a model in the loop for a job that no longer needs one. The daily, weekly, and monthly run needs a script and an execution environment. The agent writes both the dashboard and the program that updates it. After that, the agent is out of the loop until somebody changes the question.
§The Platform Is the SDLC
A marketing manager can already ask their agent to write Python, or Go, or C++. The agent will. Then someone has to read it, decide it is safe, put it somewhere it will actually run, give it credentials, watch it fail at 07:00, and do that again next quarter. Developing software is that whole loop. An agent whipping up a script, or compiling a binary, is the first ten minutes of it.
The people who want the dashboard are not going to run that loop. The platform does: parse and validate, dry-run under the author’s identity, version on save, execute as script:<name> with the author’s captured roles, schedule, audit, cap the run, and persist whatever platform.export and platform.publish_data produced. manage_script is the MCP tool. The author is whoever is talking to the agent. The operator of the script is the platform. Nobody on that team has to stand up a job runner, a secrets store, a review queue, and an on-call rotation so a dashboard does not freeze on last Tuesday.
§A Dialect, Not a Blocklist
Starlark was designed at Google for Bazel, originally named Skylark. It is a dialect of Python: dynamically typed, garbage collected, first-class functions, the syntax every model has already read. Independent threads execute in parallel, and shared data becomes immutable. I embed Google’s Go implementation, starlark-go.
The property I bought is that determinism and isolation are language rules, not a blocklist I have to keep current. Starlark has no ambient clock, no randomness, no filesystem, and no network. A script can affect the world only through bindings the host predeclares. Execute the same code twice and you get the same result. The language site calls this hermetic execution, and it is the reason the designers say it is safe to run untrusted code.
The obvious alternatives fail that requirement in different ways. Embedding CPython gives you the real language, which is exactly the problem: open, socket, datetime.now, random, subprocess, and a module system that will import whatever is on the path. JavaScript via goja is a language agents also write, but the sandbox is by omission. You strip Date, Math.random, promise scheduling, and prototype pollution, and then you do it again for the next ES feature. A JS subset cannot be proven deterministic. WASM via wazero is the strongest isolation, and the only option with a real memory cap, but the author is an agent that thinks in Python, not a guest toolchain. It remains the escape hatch if isolation requirements harden. Starlark is the one where the proof ships with the language, and the author already knows the syntax.
Python is the bait. The dialect is what I can run unattended.
§Smaller Than Python on Purpose
The absences are the product. There is no import, no try/except, no while, no recursion, no f-strings, no classes, no datetime, no random, no open, and no requests. Each one is a failure mode I do not want in a program the platform will run at 07:00 with nobody watching.
Errors fail the run on purpose, so the failure is recorded rather than swallowed. Unbounded loops are off so a script’s cost is readable from its source. There is no clock, because reading one would make a run unreproducible from its own record; the fire time arrives as a pinned value on run.fire_time. There is no network and no filesystem, because the platform is the only outside world a script has. Credentials never appear in the source. The script names a connection. The platform holds the secret and authorizes the call.
What is predeclared is small enough to keep in your head. platform.query is read-only SQL, with :name placeholders the host renders as typed literals, never string-spliced. platform.export writes an output: a CSV, a JSON feed, or a document. platform.publish_data refreshes the data region of an existing dashboard without touching its markup. platform.call reaches any other tool the author could call, a warehouse write, an API, object storage, an asset edit, each by name. print goes to a bounded run log. json and date are already in the universe. run is a frozen dict: run_id, fire_time, params. That is the script’s only source of time and of caller input.
The dialect contract is in the manage_script tool description, because a model trained on Python will reach for import, an f-string, and datetime.now() on the first draft. Stating the absences up front costs a paragraph and saves a round trip per script. The other round trip that still happens, every time, is DECIMAL: a SQL DECIMAL column arrives in the rows as a string, not a number, so you pass it through float() before you add anything or Starlark will concatenate.
A daily rollup looks like this. The date comes from the pinned fire time, so re-running months later against the same warehouse snapshot reproduces exactly what it said. The empty case is explicit. A truncated query fails the run rather than handing a dashboard a partial answer to render as complete.
report_date = date.add_days(date.of(run.fire_time), -1)
result = platform.query(
connection = "primary",
sql = """
SELECT region, sum(amount) AS total, count(*) AS orders
FROM sales.orders
WHERE order_date = DATE :day
GROUP BY region
ORDER BY region
""",
params = {"day": report_date},
)
rows = result["rows"]
if not rows:
fail("no rows for {}; refusing to overwrite the current version".format(report_date))
platform.export(
name = "daily-sales-" + report_date,
rows = rows,
format = "csv",
)
Heavy computation does not belong in the interpreter. Scripts are glue. The source cap is 256 KiB.
§Validate, Dry-Run, Then Save
The agent creates or patches a script through manage_script, then validate, which parses and reports the tools, connections, and destinations the source would reach, and executes nothing. Then run_draft, which executes for real under the author’s own identity and persona, with tighter limits, and persists nothing: an export reports the shape and size it would have written. Sending the edit with those calls is how the agent iterates without making the change live. Saving a version makes it the version that runs. run_script, the portal’s run button, and a cron schedule all execute that saved version.
A draft is capped at 2 million interpreter steps, 60 seconds, 5,000 rows, and 8 MiB per result, because somebody is waiting at a prompt. A platform run is looser, 20 million steps, 10 minutes, 20,000 rows, 32 MiB, matching the ceiling a synchronous export already applies, and still bounded on every axis. Print output is capped at 64 KiB. One run at a time per replica.
The run authenticates as script:<name>, presenting the roles the author held when they saved that version. Those roles are captured on the immutable version row and cannot be set any other way, so a script can never do unattended what the person who wrote it could not do themselves. The middleware resolves them to a persona at every call, exactly as it does for a person. Narrowing a persona’s connection rules takes effect on the next run. There is no script-side allowlist to drift out of step with the persona configuration it would duplicate.
Every host binding is one ordinary MCP tool call over a per-run in-memory session against the assembled server. Authentication, persona and connection authorization, rate limiting, and audit all apply, with no second implementation to keep in step. Binding those functions straight onto narrow Go interfaces would be faster per call and would mean re-implementing authorization for user-authored code, which is the drift this platform’s single-funnel design exists to prevent.
§Make the Report Dynamic
A script produces a document. Two shapes, and the choice is made before the first line of the script is written.
Compose the whole document in the script when each run is its own kept document, a dated archive, a monthly close, a statement, when the structure varies with the data, or when nobody will hand-edit the presentation. The cost is that every fire overwrites the current version wholesale, so a layout edit made in the portal is destroyed by the next scheduled run.
Publish the document once and refresh only its data region when there is one stable-named asset at one URL whose layout a person may edit and whose numbers alone move per run. That is the semi-dynamic dashboard. The template stays in the asset. The data stays in the script.
data = {"regions": platform.query(connection="warehouse", sql="SELECT ...")["rows"]}
platform.publish_data("revenue-dashboard", data)
name is the same output identity platform.export uses: one (script, output name) pair is one portal asset, and every run adds a version of it. A year of weekday fires leaves one dashboard with a year of versions, which is the bookmark, the share, the public URL, and the history. Compose the period into the name when you actually want an archive.
The document marks its data region with exactly one element, id="data", conventionally a JSON island the page’s own code reads:
<script type="application/json" id="data">{"regions": []}</script>
<script>
const data = JSON.parse(document.getElementById("data").textContent);
// render from data
</script>
The platform serializes the payload and structurally replaces that element’s interior, leaving every other byte of the document as its author wrote it. A public share works with no view-time fetch. An old version still shows exactly the data it showed. In a draft, nothing is written.
The ACME demo has a weather-watch dashboard over six data-center locations that is this shape. The layout lives in the asset. A scheduled script fetches the National Weather Service forecast for each site through api_invoke_endpoint and replaces the JSON in the #data island. Redesigning the page does not require touching the script. The people looking at Phoenix and Dallas do not get a weather API key, and they do not get the agent’s session.
§The Script Is the Narrowing
The agent’s access is broader than the reader’s on purpose. That is the security model. During design, the agent can query the warehouse and invoke APIs. The script it writes pulls the exact slice the page will render. The platform runs that program as script:<name>, through the same middleware an interactive query goes through. The output is a versioned document. Sharing it is a share of the asset, not a grant of the script and not a grant of the warehouse. Put it on a public page and you have published bytes, not a query API.
Row-level security, column projection, and which connections exist stay where they already live, on the persona, at the moment of the query. The dashboard user is not in that path.
Caution: a script’s output can reach somebody who could not have produced it. That is the point of a report. Ownership is the control on the other side. A script is one person’s. That person is who can edit it, run it, and schedule it. Moving it to somebody else is an administrator’s action, because a transfer hands over the history, including run logs that may echo rows the new owner has no access to of their own, and from then on a run presents the transferring administrator’s roles.
§No Memory Cap
Starlark-go bounds CPU with an execution-step cap and wall-clock through thread cancellation. The runner adds a hard byte cap on every host result and bounded log capture. A result the engine truncated at the row cap fails the run rather than being handed over as complete.
There is no hard memory cap. Neither starlark-go nor any comparable embedded interpreter of this class offers one. A pathological script can still grow the process heap, because allocation per step is unbounded. The mitigations are the step limit, the wall-clock deadline, the host-side result caps, one run at a time per replica, GOMEMLIMIT at the process, and a worker-role flag so the same binary can run script execution on dedicated replicas where an OOM cannot take down serving. Residual risk sits behind those controls, and behind the fact that a saved script runs with the author’s captured roles.
Standing authority is inherent to unattended automation. The roles a version captured keep working over a weekend, and after the author leaves. The controls over that are the script lifecycle, disable, deprecate, supersede, the persona filter’s live resolution of those roles, and the audit trail. A schedule writes a run row and nothing else. The run gate and the persona filter still decide what that row may do.
What deterministic means here is narrower than it sounds, and that is the contract: same script version, same parameters, same underlying data, same output. The warehouse still changes between runs, and that is the point of re-running. The promise is that the script contributes no variation of its own. No clock, no RNG, no enrichment that varies with catalog state. The fire time is a parameter, not a now().
mcp-data-platform already gave a frontier model governed access to the warehouse, the catalog, and the APIs behind the platform. Managed scripts are the next object on that surface: the agent writes the dashboard and the Starlark that updates it, the platform stores and runs the program after the agent is gone, and the audience holds a document. The readers get the report. They do not get a warehouse query or an API.