Pia Mesh
Pia Mesh is the part of Pia Server that lets capabilities live outside the server process. The server (“Core”) stays the orchestrator: it holds the catalog, the authorization, and the reasoning loop. A pod is a worker that attaches to Core and contributes capability to it.
The governing rule is that Core can host every capability in-process. Moving a capability to a remote pod is a configuration decision behind the same contract — never a rewrite. The knowledge base is the proof: it runs in-process and implements exactly the interface a remote pod implements.
Pods are not a closed set. A deployment’s own developers can write one: Writing a pod is the contract — the two transports and which planes each can serve, the wire rules, and the boundaries that decide what belongs in a pod at all — and Pia.Mesh.Blueprint is the public repo carrying the runnable half: an agent-readable contract, the generated schemas, and a sample with an offline harness.
Two planes
Section titled “Two planes”Mesh separates two kinds of work that are easy to conflate.
| Tool plane — connectors | Task plane — operators | |
|---|---|---|
| Unit of work | one tool call | one assignment (a goal) |
| Who runs the loop | Core’s model | the operator |
| Duration | short, inside a chat turn | long-running, cancelable, survives restarts |
| Result | a tool result | an artifact, plus a progress log |
| Protocol | MCP | Pia’s own assignment API, executed on Temporal |
| Entry point | the chat request | POST /api/assignments |
Both are off unless an administrator configures them, and neither is addressable as a row: connector and operator catalog rows never appear in the plugin catalog and never sync. The tool plane has no client-facing surface at all. The task plane has exactly one — a client may read the skills its user has been granted (name, display name, chat mode, the record types the skill accepts) and enqueue an assignment against one of them. Which catalog row serves that skill, and whether a pod is behind it, is never projected.
The tool plane
Section titled “The tool plane”The connector contract
Section titled “The connector contract”Every tool source implements one interface, IConnector (src/Pia.Server/Connectors/), with MCP semantics: list tools, and call a tool with a server-resolved scope. Two implementations ship:
| Implementation | Location | Backed by |
|---|---|---|
KnowledgeConnector |
in-process | The knowledge base |
McpConnector |
remote | An MCP server, over one of two transports |
Both pass the same contract test suite. That shared pass is the parity guarantee: a capability that moves from in-process to remote does not change behaviour on the way.
How a chat request picks up tools
Section titled “How a chat request picks up tools”- On each chat request, the registry activates every provider in registration order. The knowledge-base provider is always first, so a pod can never shadow
search_knowledge_baseor take over the lead of the system-prompt addition. - The remote provider loads the caller’s group-granted connector rows, and for each row that is present (a pod connected, or a reachable HTTP target) it opens or reuses a session and lists its tools.
- All tools from all connectors are merged into one session. Tool-name collisions resolve first-registered-wins: the later duplicate is hidden and a warning is logged naming both connectors. An ambiguous name is never routed.
- The merged tool definitions go to the model in OpenAI function-calling shape. When the model calls one, Core routes it to the owning connector.
Scope is always server-resolved. The user id and the access scope are attached by Core from the authenticated request and travel to a remote pod in the MCP request’s _meta — the model cannot influence them, and reserved _meta / _pia_* keys are stripped from model-supplied arguments before dispatch.
Failure semantics
Section titled “Failure semantics”A connector that throws mid-loop does not fail the chat. Core converts it into an {"error":"connector_unavailable"} tool result so the model can answer around it. The round still counts against the three-round server-tool cap.
A row that repeatedly fails to connect is held dark on a backoff of 0, 0, 5s, 10s, 20s, 40s, 60s, capped at 60 seconds. The first failure is free, because the common case — a transport that reconnects in a few seconds — is one failed activation followed immediately by a good one.
Transports
Section titled “Transports”A connector row picks one of two transports.
uplink — the pod dials out to Core and holds a SignalR connection open at /hubs/pod-uplink. MCP JSON-RPC is tunneled over that connection. This is the default and the right choice for a pod behind NAT or a customer firewall: nothing needs to be inbound-reachable, and the pod inherits the uplink’s authentication and presence.
streamable_http — Core is the client and dials the target’s MCP endpoint over HTTP. This is for URL-reachable MCP servers, including third-party ones. The URL must be https, or http on loopback for local development. Only this transport supports outbound authentication.
The pod card advertises; the grant authorises
Section titled “The pod card advertises; the grant authorises”When a pod connects it sends a pod card — its id, version, and the tools it offers. The card is an advertisement, and this is the invariant the whole tool plane rests on: no field on a card grants anything. Authorisation is the group grant on the connector or operator row, made by an administrator; a card can only ever narrow what Pia will do with a pod, never widen it. A field a pod declares that Pia does not act on is therefore ignored rather than honoured, which is correct behaviour and not a gap.
The card is also not the callable set. That comes from a live tool listing over the session, so a card advertising three tools and a session answering with none is a legitimate state — the admin UI says advertised for exactly that reason.
Presence, and what “online” doesn’t tell you
Section titled “Presence, and what “online” doesn’t tell you”Presence is in-memory and connection-scoped. It does not survive a restart, on purpose: a persisted “online” row would lie to the router after a crash. History lives in the audit log instead, via Connector.PodConnected / Connector.PodDisconnected events.
Presence is only ever evicted by a disconnect — there is no timer and no sweeper. Deactivating or deleting a row therefore drops the pod’s connection, and the disconnect that follows is what clears presence. That keeps a single eviction path rather than adding a second way for the two to disagree.
Presence alone is not health. A pod whose MCP pump has wedged keeps its SignalR connection alive on the transport’s own pings, so it would read as online forever. The admin UI therefore reads the session as well and reports such a pod as Not answering — see Admin → Connectors.
The task plane
Section titled “The task plane”Assignments
Section titled “Assignments”An assignment is a durable unit of work handed to an operator. Unlike a tool call, it has states, a progress log, an artifact, and cancellation, and it outlives the request that created it.
Execution runs on Temporal. Pia embeds only a Temporal worker; the Temporal service itself is a separate container. Core’s own assignments and assignment_events tables are the record of truth — losing the Temporal store costs in-flight runs, not history.
Lifecycle
Section titled “Lifecycle”Queued ──► Running ─┬──► Completed (artifact written) ├──► Failed (error code + message) └──► CancelledStatuses are persisted as strings, so the set can grow without renumbering anything.
Each transition appends to an append-only event log (assignment_events) with one of eleven kinds: queued, started, step, continued, tool, artifact, deauthorized, completed, failed, cancelled, terminated. Progress is polled, not pushed — GET /api/assignments/{id} returns the status and the event log. The list route deliberately omits events entirely rather than returning an empty array, which would read as “no progress”.
Each event carries prose for a person and, on the kinds where something structured applies, the same fact as data: the step index, that step’s token delta, the tools it resolved, and — on a de-authorisation — which of them were lost. Those are written by the step activity, in the step’s own transaction, because the workflow’s activity sequence is replay-sensitive and a change to what an activity writes needs no place in it.
continued is the one exception and the one kind the workflow itself writes: a note recorded between two steps, which only a multi-step skill has anywhere to put. Adding that call did change the replay-sensitive sequence, so it sits behind a Temporal patch gate — a run already in flight when it deployed finishes on the sequence it started with.
Model output never appears in a workflow value. A step’s summary reaches the next step by being written to the event log and read back, and the column that carries it is capped — an over-long summary is truncated, and says so, rather than failing the insert that the at-most-once guard depends on.
The event log is the only place two different Failed rows become distinguishable: one force-failed by the reconcile pass and one killed by an administrator both land on Failed, and only terminated versus failed tells them apart.
Caps and safety rails
Section titled “Caps and safety rails”Every knob below is an Operators:* setting; all but the runtime gate itself hot-reload.
| Cap | Default | What it bounds |
|---|---|---|
MaxSteps |
8 | Steps in one assignment |
WallClockTimeoutSeconds |
900 | Total run time |
MaxConcurrentAssignments |
4 | Worker-wide concurrency |
MaxConcurrentAssignmentsPerUser |
2 | One user’s in-flight assignments, refused at enqueue with a 429 |
MaxActivityAttempts |
3 | Retries of a single step |
PerAssignmentTokenCeiling |
200,000 | LLM spend for one assignment |
QueuedGraceMinutes |
5 | How long a Queued row waits before the reconcile pass owns it |
MaxReconcileAttempts |
5 | Reconcile tries before a row is force-failed |
RetentionDays |
30 | Age at which a finished assignment and its events are deleted |
Two background services back these. The worker executes assignments; a retention service runs every five minutes to sweep expired rows and to recover rows that were written but whose workflow never started. Both self-idle when the runtime gate is off, so a server with operators disabled pays nothing for them.
The built-in skills
Section titled “The built-in skills”Two operator skills ship. research answers in a single pass and runs in the Research chat mode. brief runs three — survey, outline, compose — in the Assistant mode. Both modes are assistant-class, so both skills have knowledge-base grounding available to them. The skill name and mode come from the operator’s own descriptor, never from the request body — a caller cannot steer an assignment into a different mode.
A step decides whether it is the last one; the workflow, never the operator, owns the loop. brief’s pass count is fixed and its final pass always produces the artifact, so a run terminates on its own and MaxSteps stays a cap rather than becoming the termination condition. Reaching it means the cap is configured below what the skill needs.
That distinction is what makes several rails real rather than theoretical. The step cap, the per-assignment token ceiling accumulating across passes, the de-authorisation baseline (the first step’s resolved tool set, re-checked on every later step) and the continued note all require a second step to exist at all.
A skill is registered in code and selected by an operator catalog row, through a discriminator in the row’s ConfigJson (the same column a connector row’s transport rides). Activation returns one operator per granted row, so several rows can serve several skills at once. Three rules keep that unambiguous:
- A row that names no skill serves
research. Every row written before rows could select one is that shape, so upgrading changes nothing. - A row that names an unknown skill is skipped, with a warning naming it — never defaulted, because silently serving
researchfor a row an admin pointed elsewhere is the same class of silent-wrong-behaviour the mode literal exists to prevent. - Two rows on the same skill collapse to the oldest, with a warning naming both. This mirrors the tool plane’s first-registered-wins rule, and it is a correctness requirement rather than a tidiness one: the enqueue route and the step activity resolve operators independently, so a duplicate would make “which row serves this skill” an ordering coincidence between two code paths.
The chat mode stays a compile-time property of the skill class. There is deliberately no mode setting on a row.
What the client half does with all this
Section titled “What the client half does with all this”The desktop client reaches the task plane through the assignment API and nothing else: it probes the granted skills, enqueues, polls, cancels, and acknowledges. Four of its behaviours are load-bearing for the guarantees above rather than being presentation:
- The surface hides itself. No server, no token,
401/403/404or an empty skill list all mean the entry points are absent rather than disabled, so a server with no operator rows produces a client with no trace of the feature. - Consent is minted once per assignment, before anything is read. The send path takes a consent receipt as a required argument, the local consent log is the only thing that can write one, and it is session-scoped — which is what makes “no background caller can send” a property rather than a promise.
- The client refuses against the same caps the server enforces, using the constants the shared contract publishes, so an over-size selection never becomes a
400a user cannot act on. - Commit, then acknowledge. The artifact is written locally as an ordinary assistant chat before
collect, because collect is irreversible. Writing it as a chat is also the whole re-encryption: it syncs as ciphertext like any other chat.
Full behaviour, including what the consent screen must state and what the client keeps on disk, is in Background assignments.
The catalog
Section titled “The catalog”Both pod kinds are rows in the same Plugins table the desktop plugin catalog uses, discriminated by Kind:
Kind |
Describes | Admin page |
|---|---|---|
connector |
A remote Mesh pod on the tool plane | /admin/connectors |
operator |
An operator on the task plane | /admin/operators |
Configuration rides the row’s ConfigJson; neither kind needed a schema change.
Authorization reuses the existing group allowlist, but the grant is made from the connector’s or operator’s own page — the group editor’s plugin picker deliberately cannot see these rows.
One consequence worth knowing: editing a pod row bumps the client catalog version like any other plugin write, so clients do one extra no-op catalog pull afterwards. Deleting a row drops its group grants in the same transaction, so nothing stale survives a delete.
Where Mesh sits in the request path
Section titled “Where Mesh sits in the request path”chat request └─ AiProxyController └─ GuardrailChatRouter ─► IConnectorRegistry.ResolveAsync(userId, mode) ├─ KnowledgeConnectorProvider (in-process, KB) └─ RemoteConnectorProvider (0..N pod rows) ├─ uplink ─► /hubs/pod-uplink ─► pod └─ streamable_http ─► HTTPS ─► MCP server
POST /api/assignments └─ AssignmentsController ─► IAssignmentRuntime ─► Temporal ─► OperatorWorker └─ CatalogOperatorProvider ├─ granted row ─► skill ─► IOperator └─ the same connector registryOperators consume connectors through the same registry Core’s chat loop uses. There is no second tool path.
Trust model
Section titled “Trust model”The interactive plane is unchanged: everything a user touches interactively stays end-to-end encrypted, and Mesh does not weaken it.
The background plane is different by design. An assignment’s input and artifact are plaintext in Core’s database, because whatever runs the work has to be able to read it. The guarantee there is isolation, not encryption: every assignment row carries a user id, every route is user-scoped, and another user’s assignment answers 404 on read, list and cancel. PostgreSQL row-level security on the assignment tables is a second layer on top of that rather than a replacement for it: where the deployment separates the runtime, migration and maintenance database roles, a query that forgets its user filter reads nothing and a write outside the store seam fails, while DELETE cascades and the rest of the schema stay outside its scope — see Pod uplink and connector credentials for the full boundary, including what a pod can and cannot see.
Crossing between the planes is declared, scoped and bounded
Section titled “Crossing between the planes is declared, scoped and bounded”There is exactly one crossing, POST /api/assignments, and three properties make it a gate rather than a hole. None of them is the client being careful.
Declared. The input is a versioned envelope naming every record it carries, not an opaque blob. A client that wants to include one of the user’s memories has to say so, in a field the server reads.
Scoped. Each skill declares in code which kinds of record it accepts, and the server refuses anything else. Two checks run in order: the kind must be in a closed vocabulary shared by both halves of the system, then it must be one that skill declared. Neither the operator row nor a pod card can widen the declaration — a card is an advertisement, and an admin-editable scope would widen every user of that skill at once. A skill that declares nothing gets a prompt and no records at all.
The vocabulary is user-authored content only — assistant chats, sessions, memories, todos and custom prompt templates. Credentials and configuration are excluded by construction: providers, settings, certificates, plugins and plugin preferences are not members, and no skill has a reason to read them.
Bounded. At most 20 items, 8 000 characters each, 32 000 in total, 4 000 for the prompt, refused before anything is stored — and the plaintext is dropped within hours of the run finishing rather than living out the row’s retention window. See Retention.
What is not claimed: nothing here stops an operator with database access from reading the plaintext while it is there. That residual is the trade the background plane makes, and it is why the crossing is narrow, consented and short-lived rather than merely encrypted-in-transit.
The consented half of that is enforced on the client, because that is the only place a person is: the desktop client names every record on the screen where the affirmation happens, states the residual above in as many words, and cannot send without a receipt minted there — see Background assignments.