MeshWeaver runs C# scripts on a per-Activity Roslyn kernel hosted inside the mesh. Scripts have first-class access to the live IMessageHub, so they can post messages, mutate nodes, and stream results just like compiled hub handlers — but without a recompile cycle.

This page covers three things:

  1. How to launch a script — from a Code node, from application code, or from an MCP agent.
  2. How to emit progress — so subscribers see live updates as work unfolds.
  3. The architecture — how the host hub stays responsive while a script runs.
Code Node ExecuteScriptRequest App Code SubmitCodeRequest MCP Agent execute_script Activity Hub KernelContainer forwarder ActivityLog content Status: Running / Done forward Executor Hub KernelExecutor child hub Roslyn kernel runs script Mesh · Log · Ct · Inputs DataChangeRequest Subscriber GetRemoteStream(MeshNodeRef) live ticks All entry points converge on the Activity hub; the Executor child hub runs scripts off-thread and pushes incremental snapshots back. *Script execution architecture: three entry points converge on the Activity hub, which delegates to a child Executor hub and streams progress snapshots back to subscribers.* > **Lifting an existing operation onto script execution?** Read *[Activity Control Plane → Operations as scripts](/Doc/Architecture/ActivityControlPlane#operations-as-scripts--the-canonical-shape-for-export-import-compile-)* first. That section is the canonical shape for export, import, compile, mirror, and similar operations: inputs bound DIRECTLY to the activity node (a node-bound editor, never a `/data` replica + save loop) → patch `RequestedStatus = Running` → activity-driven progress → result panel subscribes to the same activity. This page documents the lower-level mechanics; that page documents the user-facing pattern.

Launching a script

There are three entry points, all converging on the same Activity hub and the same progress stream.

1. From a Code node — ExecuteScriptRequest

This is the canonical path. Create a Code MeshNode with IsExecutable = true, then post ExecuteScriptRequest to the node's address. The node's hub:

var meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
await meshService.CreateNode(new MeshNode("daily-rollup", "rbuergi")
{
    Name = "Daily rollup",
    NodeType = "Code",
    Content = new CodeConfiguration
    {
        Code = @"
            Log.LogInformation(""Starting rollup..."");
            // ... real work ...
            Log.LogInformation(""Done — wrote {Count} rows"", 1234);
        ",
        IsExecutable = true,
    }
});

// Fire it. The response carries the ActivityLog path for live subscription.
hub.Observe<ExecuteScriptResponse>(
        new ExecuteScriptRequest(),
        o => o.WithTarget(new Address("rbuergi/daily-rollup")))
    .Take(1)
    .Subscribe(resp =>
    {
        var activityPath = resp.Message.ActivityLog!;   // e.g. rbuergi/daily-rollup/_Activity/{guid}
        // ... subscribe to progress, see next section ...
    });

Each call to ExecuteScriptRequest creates a new Activity node in the user's home partition (e.g. rbuergi/_Activity/{guid}) — not nested under the Code node. The originating Code node is preserved on the Activity's MainNode and ActivityLog.HubPath. Two reasons for this placement:

Reason Detail
Natural activity log The user's partition root is the right home for every script run, regardless of which Code node triggered it.
Reliable routing Top-level satellite paths route predictably; deeply nested satellite paths require an extra materialization step that races CreateNodeSubscribeRequest and frequently times out.

Historical runs accumulate as siblings under {partitionRoot}/_Activity/*, and the Code node's LastExecutedAt field is stamped on each run. A Run button rendered on Code views (visible when the caller has Permission.Execute) wires this exact request.

What a cell may claim about its own output — the currency rule

The stamp writes four fields together — LastExecutedAt, LastExecutedBy, LastActivityPath and LastExecutedCodeHash — and the last of them is the proof the other three lack: the CodeFingerprint of what that run actually submitted. A view re-computes the fingerprint from the node's current Code/Language and compares, which is how a cell knows the output pane below it belongs to source the reader has since edited.

🚨 The stamp is a write, and a write can fail. It is dispatched fire-and-forget after the run has already been acknowledged (the Run click must never be silent), so a failure reaches a log and nothing else. That leaves a node that ran while recording only part of what it ran — and a boolean "is it stale?" has nowhere honest to put that. Answering not stale asserts a currency nothing substantiates: a wrong claim, not a missing one.

So the verdict is not a boolean. CodeConfiguration.OutputCurrency() (MeshWeaver.Mesh.Contract) is the one rule every surface must use, and it fails closed:

State When What the cell may show
NeverRun no run recorded at all — no fingerprint, no timestamp, no activity path, no runner nothing; a cell nobody has run has no output to be wrong about
Current a run is recorded and its fingerprint reproduces from the code on screen "up to date" — the only state that may say so
Stale a run is recorded, its fingerprint was recorded, and the code has moved since "code changed — re-run"
Unverified a run is recorded but its fingerprint is not "output unverified — re-run to be sure"

Unverified is the fail-closed state, and it is deliberately neither of the two it sits between. It is not Current, because nothing proves the output belongs to the code above it. It is not Stale either: every node last executed by a build that predates the fingerprint field would light up amber at once, which trains readers to ignore the indicator — the failure mode the fingerprint's own normalization rules exist to avoid.

"A run is recorded" is deliberately generous — any of the four stamped fields present, not LastExecutedAt specifically — so a stamp that landed only in part cannot fall back into NeverRun and go silent on a cell that ran.

The fingerprint is tested first, before the other three. It is both evidence of a run and the only field that can decide currency, so a node carrying the hash and nothing else is fully determinable; checking the run markers first would answer NeverRun there and silence a verdict we can actually substantiate — the same fail-open shape, mirrored.

The residual, stated rather than hidden: a cell whose very first run failed to stamp anything at all records nothing, and is therefore indistinguishable from a cell nobody has run. No rule over the node's content can recover that. What happens instead is below.

When the stamp does not land

The stamp is one write to one node. It lands whole or not at all — there is no partial outcome to read a warning out of — so a cell whose stamp failed reads exactly as NeverRun. Recovering that on the cell is not possible from the dispatching hub, and the reasons are worth writing down because all three candidate shapes look plausible until you follow them:

Candidate Why not
Put a LastStampFailedAt marker on the cell It records the failure of a node write by performing another node write to the same node through the same handle. In every failure mode that actually occurs — the workspace not ready, the partition write refused, the content unreadable — the second write fails for the reason the first one did. Recovery in the same failure domain is not recovery.
Append the failure to the run's Activity The Activity is a different failure domain, and it is where FailActivity already writes. But for the in-process C# path the activity's transcript has a single writer that re-asserts it wholesale: ActivityLogLogger.PublishSnapshotLocked writes Messages, MessageCount, MaxSeverity, Status and End from its own private list on every flush and on Complete. An append issued from the dispatcher — which runs immediately after the submit, i.e. before the kernel has published anything — is overwritten by the next snapshot. Worse if it survived: an Error-level line rolls MaxSeverity up, and a terminal write that folds severity would report a successful run as failed.
Have ExecuteScriptResponse carry the verdict The response is posted before the stamp resolves. Waiting for it undoes MeshWeaver.Plugins#1266, whose whole point is that a Run click is acknowledged immediately and never silently. A stamp write that stalls would then hold the acknowledgement of a run that is already executing.

So the failure is reported, not recovered: CodeNodeType.ReportStampNotRecorded logs one line at Error, under event id 3249, naming the cell, the activity and the fact that the run itself succeeded.

Two things make that an honest answer rather than a shrug.

The run's durable record is not lost. The Activity node was created before the dispatch and it carries the originating cell on ActivityLog.HubPath. A failed stamp loses the cell's pointer to its run, not the run. A listing over _Activity can still find it.

Every way the stamp can fail now reaches that one line. It previously had two log calls and a third path with none: the update lambda tested curr.Content is CodeConfiguration, so content arriving as untyped JSON — the polymorphic converter degrading an unresolvable $type, the as-written JsonObject DOM, a same-named record from another collectible assembly — simply did not match, the write short-circuited as a no-op, and the stamp vanished with no exception and no log at all. The stamp now goes through the typed Update<CodeConfiguration> overload, which refuses to write unreadable content and faults with the runtime type and a JSON excerpt instead of silently writing a default-valued record over the cell.

Error rather than Warning is deliberate and costs nothing extra: both levels ship to Loki and the path is exceptional, so the only thing that changes is that it trips error-rate alerting. That is the right classification — nothing retries the write, the loss is permanent, and the visible consequence is a cell that tells a human it has never run. "Degraded but self-correcting" is what Warning claims, and none of it holds here.

Recovering the run for the READER — follow the edge, do not write another marker

The three candidates above all fail for the same reason: they are writes. What the reader needs is a read, and the edge to read along already exists. CodeRunHistory.ResolveOutputCurrency (MeshWeaver.Mesh.Contract, beside the currency rule it extends) walks it:

// The verdict a cell can substantiate when its own stamp cannot supply one.
// Reactive like every other read — one verdict, then completion.
code.ResolveOutputCurrency(cellPath, viewerHome, meshService)
    .Subscribe(verdict => { /* NeverRun · Unverified · Stale · Current */ });
Step What happens Cost
1 OutputCurrency() — the stamp. Anything but NeverRun is returned unchanged. no query
2 Only on NeverRun: one listing for an Activity naming this cell on HubPath. A hit ⇒ Unverified. one row
3 No hit ⇒ NeverRun, exactly as today.

Three properties are what make this the right shape rather than merely a working one.

It is a listing by predicate, not a point read. Query is eventually consistent, so it may never decide what a specific known node contains — that stays GetMeshNodeStream's job. Here a stale negative is harmless by construction: the worst outcome is the answer the cell already gives today.

A recovered run is Unverified, never Current. The Activity records that the cell ran, never what it ran — no fingerprint travels with it. That is the same evidence a stamp which landed without its hash leaves, so it gets the same fail-closed verdict, for the same reason.

It runs only where it can change the outcome. A notebook of cells that ran normally costs zero queries, because step 1 answers first. This is the whole reason the lookup hangs off NeverRun rather than being a general "find this cell's runs" call every view makes.

Where it looks. The run lands under {activityParent}/_Activity, and CodeNodeType.ResolveActivityParent picks that parent in three layers: the Code node's own CodeConfiguration.ActivityParentPath, then the partition's PartitionDefinition.DefaultActivityParentPath, then the partition root — with the {viewer} sentinel at either configured layer expanding to the calling user's home. CodeRunHistory.ActivityNamespaces reconstructs the layers it can see from the cell alone — the node's own ActivityParentPath with {viewer} expanded, the partition root, and the reading viewer's home — deduplicated into a single union query, usually one namespace. A partition-level DefaultActivityParentPath pointing somewhere else is not covered: that lookup is a live query on the partition registry, which MeshWeaver.Mesh.Contract sits below. Where it applies the listing finds nothing and the verdict falls back to NeverRun — the answer the cell gives today, so the gap costs nothing that was already working. A caller that has already resolved the parent (the dispatcher has, via CodeNodeType.ResolveActivityParent) skips the derivation and calls CodeRunHistory.RunsQuery with the namespace it resolved.

🚨 Two cross-assembly agreements hold this together, and neither is visible to a compiler. The lookup filters on nodeType:Activity and expands the {viewer} sentinel — both declared in assemblies it sits below (GraphNodeTypeNames.Activity, CodeNodeType.ResolveActivityParent). A rename on either side would leave the query looking for something nothing writes: zero rows, every recovered cell silently back to NeverRun, no compiler error anywhere. CodeRunHistoryTest pins both — the node-type name by equality, the sentinel by driving the dispatcher's own resolver and requiring the answer the lookup assumes.

What remains uncovered. The lookup answers whether the cell ran, not what it produced: it deliberately does not re-point the output pane at the recovered activity. And an operator still gets the Error under event id 3249 — the stamp failing is a real fault, and a reader who can no longer see the consequence is not a reason to stop reporting it.

2. From application code — SubmitCodeRequest directly

For one-off submissions — interactive markdown cells, REPL-style flows — where you already own an Activity hub address:

hub.Post(
    new SubmitCodeRequest(@"Log.LogInformation(""hello""); 1 + 1") { Id = "cell-7" },
    o => o.WithTarget(activityAddress));

Progress flows into the activity hub's ActivityLog content the same way as for ExecuteScriptRequest.

3. From an MCP agent — execute_script

Agents call the same path as ExecuteScriptRequest but through the MCP tool surface. Activity creation and activity-log streaming behave identically. Agents authoring a new script must follow the same progress conventions below so a human watching the run sees it unfold in real time.

// Agent-side tool call
{
  "tool": "execute_script",
  "args": { "path": "rbuergi/daily-rollup" }
}

The tool returns the dispatch verdict, not the run's result — it comes back as soon as the run is accepted, and the script keeps going:

// Accepted. `activityPath` is what the OWNING hub created — never a path the caller
// reconstructed, so it is correct even when ActivityParentPath / the partition default /
// the {viewer} sentinel route the activity somewhere other than the Code node's partition.
{ "status": "Dispatched", "path": "rbuergi/daily-rollup",
  "submissionId": "…", "activityPath": "rbuergi/_Activity/…" }

// Refused or faulted. There is NO activityPath — nothing was created, and nothing is pending.
{ "status": "Error", "path": "rbuergi/daily-rollup",
  "message": "Not executable: 'rbuergi/daily-rollup' has CodeConfiguration.IsExecutable = false." }

Dispatched therefore means "an Activity node exists at activityPath", and every way the dispatch can fail — the node is missing or unreadable, it is not executable, the Activity could not be created, the request never routed, the acknowledgement never came — arrives as status: "Error" carrying the reason (and the exception type where there was one). The owning hub logs the same verdict at Warning/Error on the MeshWeaver.Graph.CodeNodeType channel, so an operator can find a failed dispatch in pod stdout without the caller's transcript. Poll get @{activityPath} for progress and the terminal status; a script's own exception surfaces there, not in the dispatch verdict.


Creating typed / restricted-partition nodes — the execute_script escape hatch

The MCP create and patch tools validate a node's content.$type against the hub they run on. Some content types are registered only on a dedicated per-type hub (via WithContentType<T>()), not on the general MCP hub — so raw create rejects them. Invitation is the canonical example:

Content … carries the polymorphic discriminator '$type': 'Invitation',
which is not a registered content type for the built-in NodeType 'Invitation'

The same wall guards writes into a restricted partition — e.g. the Admin partition, which ordinary identities can't write to directly (see Access Control).

The way through is to run the write inside the mesh, through the canonical service, via execute_script. The script's Mesh global resolves any registered service and the kernel references every loaded assembly — so you call exactly what the GUI calls. The write then routes to the owning hub (which does know the type), and the service's ImpersonateAsSystem() scope satisfies the partition write-guard.

Recipe — three MCP calls:

  1. create a throwaway executable Code node. CodeConfiguration is a registered content type, so create accepts it:
{
  "id": "InviteUsers", "namespace": "rbuergi", "name": "Invite users (delete me)",
  "nodeType": "Code",
  "content": { "$type": "CodeConfiguration", "language": "csharp", "isExecutable": true,
               "code": "/* the script in step 2 */" }
}
  1. The script body resolves the canonical service and Subscribes (the service impersonates System internally to reach the Admin partition):
using System.Reactive.Linq;
using MeshWeaver.Messaging;                 // AccessService, ReactiveCompletion.ObserveCompletion
using MeshWeaver.Mesh.Services;             // IMeshService
using Microsoft.Extensions.DependencyInjection;
using Memex.Portal.Shared.Authentication;   // InvitationService

var sp  = Mesh.ServiceProvider;
var svc = new InvitationService(
    sp.GetRequiredService<IMeshService>(),
    sp.GetRequiredService<AccessService>());

foreach (var (email, name) in new[]
{
    ("ada.lovelace@example.com", "Ada Lovelace"),
    ("grace.hopper@example.com", "Grace Hopper"),
})
{
    // 🚨 ObserveCompletion, never Rx's own observable-to-Task bridge (maintainer,
    // 2026-08-30: "no ToTask ever") — and never a bare `await …FirstAsync()` either, because
    // Rx's awaiter resumes the rest of this script INLINE on whichever hub thread signalled.
    // FirstAsync (not Take(1)): the next line dereferences node.Path, so an empty completion
    // must keep FAULTING rather than settling with null.
    var node = await svc.CreateInvitation(email, invitedBy: "rbuergi", note: name)
        .FirstAsync()
        .ObserveCompletion(
            ex => Log.LogWarning(ex, "CreateInvitation faulted AFTER the wait settled"),
            Ct);
    Log.LogInformation("Created {Path}", node.Path);
}
  1. execute_script the node, confirm status: Succeeded on the returned activity, then delete the throwaway node.

The effect is identical to the equivalent GUI action — including any node-driven follow-up (here, the invitation email that InvitationEmailSender sends for every Pending invitation it hasn't emailed yet).

This is a break-glass / admin path, not an application pattern. Application code, agents, and the GUI write through the typed service or stream.Update directly — never raw MCP create for these types. Reach for execute_script only for one-off operational writes the MCP surface legitimately can't express.


Writing progress in scripts

Every script receives four globals (MeshScriptGlobals, MeshWeaver.Kernel.Hub):

Global Type Purpose
Mesh IMessageHub Full mesh access — post messages, subscribe to streams, mutate nodes.
Log ILogger Each call appends to ActivityLog.Messages and flushes a snapshot to all subscribers within milliseconds.
Ct CancellationToken Rebound per submission. Pass it to every cancellable async API so user-initiated cancellation actually interrupts in-flight work.
Inputs IReadOnlyDictionary<string, JsonElement> Caller-supplied payload, forwarded from ExecuteScriptRequest.Inputs via SubmitCodeRequest.Inputs. Empty for the plain REPL / launch-button case. Read with Inputs["title"].GetString() or Inputs["options"].Deserialize<ExportOptions>().

Inputs is what makes "operation as a script" work without a side-channel node: the form or caller patches the inputs onto the request and the script reads them as typed JSON. Values are carried as JsonElement so any JSON shape survives serialization across hub boundaries without a type-registry entry per shape.

Always pass Ct to async calls. Task.Delay(ms, Ct), HttpClient.GetAsync(url, Ct), .FirstAsync(predicate).ObserveCompletion(report, Ct) — every cancellable API should receive it. Without Ct, clicking Cancel in the Activity Control Plane sends the signal but the script can't act on it until the current await returns.

🚨 A script body is one of the few places an await is legitimate — the bridge it uses is not free choice. ObserveCompletion (in MeshWeaver.Messaging, imported by default) is the only sanctioned way to take a value out of an observable here. .ToTask(...) is forbidden repo-wide as of the 2026-08-30 ruling ("no ToTask ever"), and await someObservable / await source.FirstAsync() is the same defect wearing a shorter spelling: Rx's awaiter is an AsyncSubject<T> that completes its continuation from inside OnCompleted, so the remainder of your script runs on the mesh thread that signalled — inside the portal, holding a hub's action block. ObserveCompletion completes through a TaskCompletionSource created with RunContinuationsAsynchronously, so the signalling thread is released immediately, and its reportLateFault arm stays attached for a fault that lands after the wait settled. Never pass an empty lambda there.

Reactive waiting

Don't Thread.Sleep and don't Task.Delay(ms) without the cancellation token. For waiting on external state, the right shape is a reactive subscription on the workspace — it's both cancellable and gives you a natural place to log progress:

// Wait for a downstream node to flip to a target status — cancellable mid-flight.
Log.LogInformation("Waiting for downstream job to finish…");
await Mesh.GetWorkspace()
    .GetMeshNodeStream("rbuergi/downstream-job")
    .Where(n => (n?.Content as JobContent)?.Status == JobStatus.Succeeded)
    .FirstAsync()                         // ← not .Take(1): an empty completion must FAULT,
                                          //   or ObserveCompletion settles with null and the
                                          //   script proceeds as though the wait had succeeded
    .ObserveCompletion(
        ex => Log.LogWarning(ex, "downstream watch faulted AFTER the wait settled"),
        Ct);                              // ← Ct cancels the WAIT, so Cancel is honoured
Log.LogInformation("Downstream finished — proceeding");

Don't loop-poll; subscribe and let the workspace push you the next emission.

For longer waits with periodic heartbeats, combine Observable.Interval with a linked cancellation source:

Log.LogInformation("Crunching…");
using var cts = System.Threading.CancellationTokenSource
    .CreateLinkedTokenSource(Ct);
var heartbeat = System.Reactive.Linq.Observable
    .Interval(TimeSpan.FromSeconds(5))
    .Subscribe(t => Log.LogInformation($"Still working — {t * 5}s elapsed"));
try
{
    await DoLongWork(cts.Token);
}
finally
{
    heartbeat.Dispose();
}

Log calls — what they emit

Every Log.LogInformation(...) snapshot lands on the activity log within milliseconds of the call. Console output is captured too — each completed line becomes an Information-level entry on the same activity log.

Log.LogInformation("Starting import...");
Log.LogWarning("Skipping malformed row {Row}", row);
Log.LogError(ex, "Failed to write {Path}", path);

// Console output is captured — each completed line lands as
// an Information-level entry on the same activity log.
Console.WriteLine("Wrote 42 rows");

Rules of thumb for human-readable progress

Rules for agent-authored scripts

When an agent (Claude, Copilot, etc.) writes a script for an MCP user to run, the agent is responsible for emitting useful progress. Silent scripts are unobservable scripts.


Observing progress

Subscribers use the canonical CQRS read pattern: subscribe to the activity node's shared per-path handle and observe the ActivityLog content updating live.

🚨 Read a node by path with GetMeshNodeStream(path), never GetRemoteStream<MeshNode, MeshNodeReference>(...). GetRemoteStream is framework plumbing; using it for a node by path opens a second upstream handle instead of joining the process-wide IMeshNodeStreamCache entry — so writes made through the shared handle are invisible to your subscription. (Several XML doc comments in MeshWeaver.Kernel and on ExecuteScriptResponse.ActivityLog still recommend GetRemoteStream; they are stale — follow this page.)

var workspace = hub.GetWorkspace();
var log = workspace.GetMeshNodeStream(activityPath)
    .Select(node => node?.Content as ActivityLog)
    .Where(l => l is not null)
    .Select(l => l!);

// Wait for the run to finish and get the terminal snapshot — reactive, no await.
log.Where(l => l.Status != ActivityStatus.Running)
   .Take(1)
   .Timeout(TimeSpan.FromMinutes(2))
   .Subscribe(
       final =>
       {
           foreach (var msg in final.Messages)
               logger.LogInformation("[{Level}] {Message}", msg.LogLevel, msg.Message);
       },
       ex => logger.LogWarning(ex, "Activity stream failed for {Path}", activityPath));

For live display — a streaming UI stripe or detail view — don't filter by terminal status, and don't .Take(1) (it would freeze the binding). Project the field you care about and let the subscription tick on every snapshot:

var liveMessageCount = log
    .Select(l => l.Messages.Count)
    .DistinctUntilChanged();

Never query for the activity log with IMeshService.QueryAsync("path:..."). The query path is eventually consistent and will lag behind the workspace stream — you may miss intermediate snapshots or read a stale Status. GetMeshNodeStream(path) bypasses the index and observes the owning hub's workspace directly. See CqrsAndContentAccess.md.


How progress stays timely — the architecture

The Activity hub does not run the script itself. When a SubmitCodeRequest arrives, a thin KernelContainer forwarder spins up a hosted child hub ({activity}/_KernelExec) and delegates execution there. Scripts run inside the executor's action block; the Activity hub's action block stays free to accept new submissions and process the DataChangeRequests the script's Log writer pushes back.

sequenceDiagram participant Caller participant Activity as Activity hub<br/>(KernelContainer forwarder) participant Executor as Executor child hub<br/>(KernelExecutor) participant Subscriber Caller->>Activity: SubmitCodeRequest Activity->>Executor: forward (Observe response) Activity-->>Caller: ack Subscriber->>Activity: GetRemoteStream(MeshNodeReference) Activity-->>Subscriber: initial snapshot loop Script execution (off-thread) Executor->>Activity: DataChangeRequest (ActivityLog snapshot) Activity-->>Subscriber: tick (incremental snapshot) end Executor->>Activity: SubmitCodeResponse Activity-->>Caller: SubmitCodeResponse

Because the executor's address is internal and never exposed to clients, every external caller and every subscriber sees the kernel as a single addressable surface — the Activity hub. The forwarding is an implementation detail.


Live demo — script globals available in cells

The same Mesh, Log, Ct, and Inputs globals available inside a mesh script are also available in interactive markdown cells. This cell renders them through Controls.DataGrid — the standard way to render tabular data. Never hand-build an HTML string ($"<table>…", StringBuilder, Controls.Html(markup)) for structured data; Controls.Html is only for genuinely pre-rendered rich text.

record ScriptGlobal(string Name, string TypeName, string Purpose);

var globals = new[]
{
    new ScriptGlobal("Mesh", "IMessageHub",
        "Full mesh access — post messages, subscribe to streams, mutate nodes."),
    new ScriptGlobal("Log", "ILogger",
        "Appends to ActivityLog.Messages; each call flushes a snapshot to all subscribers."),
    new ScriptGlobal("Ct", "CancellationToken",
        "Rebound per submission. Pass to every cancellable async API."),
    new ScriptGlobal("Inputs", "IReadOnlyDictionary<string, JsonElement>",
        "Caller-supplied payload from ExecuteScriptRequest.Inputs. Empty for a plain REPL run."),
};

Controls.DataGrid(globals)
    .WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Global"))
    .WithColumn(new PropertyColumnControl<string> { Property = "typeName" }.WithTitle("Type"))
    .WithColumn(new PropertyColumnControl<string> { Property = "purpose" }.WithTitle("Purpose"))

Common pitfalls

Pitfall What goes wrong Fix
await inside a click handler wrapping ExecuteScriptRequest Click actions must be synchronous. Use hub.Post(...) (fire-and-forget) or hub.Observe(...).Subscribe(...). See AsynchronousCalls.md.
Subscribing only to SubmitCodeResponse That's the completion ack — it carries no progress. Subscribe to the activity log via GetMeshNodeStream(activityPath).
Polling IMeshService.QueryAsync for activity status Eventually consistent, will lag. Use GetMeshNodeStream(activityPath) — it observes the owning hub's workspace directly.
GetRemoteStream<MeshNode, MeshNodeReference>(...) to read an activity by path Opens a second upstream handle instead of the shared cache entry — writes through the shared handle are invisible to it. GetMeshNodeStream(activityPath).
Console.WriteLine from heavy parallel loops Every line becomes an activity-log message; flooding the log overwhelms subscribers and may DoS the workspace. Aggregate before logging — one line per step, not per iteration.
Long synchronous CPU loops with no log calls No Log call → no snapshot → subscribers see nothing. Add a heartbeat log if a phase runs longer than ~1 s.
Raw MCP create rejects a node's $type ("not a registered content type") The type is registered only on a dedicated hub, or the target sits in a restricted partition. Run the write via execute_script through the canonical service — see Creating typed / restricted-partition nodes above.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.