Aller au contenu

Writing a pod

Ce contenu n’est pas encore disponible dans votre langue.

A pod is a process you write that attaches to a Pia Server and contributes capability to it. Nothing about a pod is compiled into the server: it runs on your infrastructure, holds one credential, and speaks MCP. This page is the contract — what you must produce, what Pia does with it, and what it will refuse.

It is written for a developer on the deployment’s own team, so it says what the code enforces rather than what the feature is for. Start with Pia Mesh if you have not read it; nothing here re-explains the two planes.

The prose contract is this page. The runnable half is a separate public repo:

github.com/Pia-Ai-dev/Pia.Mesh.Blueprint — the agent-readable contract (AGENTS.md), the machine-readable schemas, and a working sample with an offline harness. Point a coding agent at it and it will build you a first pod; copy samples/HelloPod/ if you would rather start from code.

This is the first decision, and it is not a setting an administrator can flip for you afterwards: the two transports do not offer the same planes, and a pod written for one does not speak the other.

uplink streamable_http
Who dials your pod dials Pia Pia dials your pod
Language .NET, via the Pia.Mesh.Pod package any — it is an ordinary MCP server
Inbound exposure none: no hostname, no firewall rule, no proxy a routable https endpoint
Chat tools yes yes
Durable skills (assignments) yes no — refused at dispatch
Pod card yes, sent at Register none exists
Offline self-check yes not applicable — there is no card to check
Credential a pia_pod_… token your pod presents a secret Pia presents to you, optionally

Pick uplink unless you cannot run an outbound-dialling .NET process. It is the only transport that reaches both planes, it needs nothing inbound-reachable, and it is the one the checks were built for.

You implement one interface and the SDK owns the wire. That division is deliberate: the card is genuinely a handful of lines, but the transport is not hand-rollable — the uplink is message-framed rather than newline-delimited, and getting that wrong hangs rather than errors.

await using var client = new PodUplinkClient(
new PodUplinkOptions
{
ServerUrl = "https://pia.example.com",
Token = Environment.GetEnvironmentVariable("PIA_POD_TOKEN")!,
PodId = "invoices-pod-1",
DisplayName = "Invoices",
PodVersion = "1.0.0",
PodType = "operator", // omit entirely for a chat-tools-only pod
},
new MyCapabilities(), // IPodCapabilities: your card tools + your MCP server options
loggerFactory);
await client.ConnectAsync(); // connects, self-checks, then Registers the card

You do not have to remember to check the card: ConnectAsync applies the card rules locally and logs every finding before it sends, because a rejected card tells a pod only the words “Invalid pod card.” and keeps the reason in the server’s log — which you almost certainly cannot read. It logs and then sends anyway rather than refusing, so an error in your own log next to that message is the diagnosis. The other half of the check runs per session: if your card advertises tools and the MCP options you hand back advertise none, you get a warning naming the count, every time Pia opens a session.

Run the whole thing yourself at build time as well, especially if you generate cards. PodSelfCheck.Inspect(card, advertisedToolNames) returns findings with a stable rule name and a message, needs no server, no token and no network, and both the in-repo canary and the blueprint’s sample exit non-zero on any error.

PodId is not identity. It is required, and then compared to nothing: the identity Pia trusts comes from the authenticated token claim. Do not key anything on it.

The card and the tool list are two different things

Section titled “The card and the tool list are two different things”

The card is an advertisement. The routable set is what your MCP server returns from tools/list over a live session. They are allowed to differ, which is why the admin console labels the card’s tools advertised rather than promising they work — and it is why the most common way to ship a broken pod is a card that advertises three tools next to a session that answers with none.

Card rules, all enforced server-side, all in the schemas the package ships:

Rule Value
Tool-name pattern ^[a-z0-9_-]{1,64}$ — lower-case, digits, _, -
Tools per card at most 64
Whole card, serialized at most 256 KB, enforced at the transport
podType absent (means connector), connector, or operator; compared ordinally, so a wrong-cased value is refused
Duplicate names refused, compared case-insensitively
inputSchema a JSON object whose type is the string "object"

An over-size card is dropped by the transport before any validator runs, so it produces no rejection message at all — the self-check exists partly to catch that one locally.

Top-level card members Pia does not recognise are captured and audited by key, never refused: the hub answers a bad card by aborting the connection, and your reconnect loop is not under the server’s control, so refusing an unknown member would turn a forward-compatible pod into a reconnect storm. They are also acted on by nothing. A field the server does not understand cannot become a grant by arriving.

An assignment step is an ordinary MCP tool call, so a skill is a tool whose name is pia_op_<skill>, lower-cased. Three things follow:

  1. Declare podType: "operator" on the card. Without it, every dispatch fails closed as operator_pod_plane_mismatch.

  2. Advertise at least one pia_op_<skill> capability. Several are fine — one pod can serve several skills, each named by its own operator row pointing at the same connector row.

  3. Keep the run going by answering _meta["pia.final"] = false. Absent means final, which is the default every pod written before that key relies on; a malformed value means final too.

The reserved prefix is a server-side filter, not a convention you are trusted to follow: the raw name you advertise is matched before a connector row’s tool-name prefix is applied, and anything matching is withheld from every chat turn. So a pia_op_ tool on a pod that did not declare operator exists and can never be called — the self-check warns about exactly that.

A step must finish inside the connector row’s call timeout (30 seconds by default, 300 at most), not the assignment’s 900-second budget. Longer work is split across steps. A remote step also contributes zero tokens to the assignment: a pod-reported count is untrusted input, and honouring it would make the per-assignment token ceiling enforce a number the pod chose.

Here Pia is the client. You publish an ordinary MCP server over streamable HTTP and an administrator points a connector row at its URL. There is no SDK, no card, no Register, and no self-check — and the tool plane is all you get.

What the server requires of you:

  • The URL must be https, or http on loopback for local development. So must an OAuth token endpoint, if you use one.
  • Answer tools/list and tools/call. The list you return is the routable set; there is no advertisement step.
  • Read the scope from the request’s _meta, exactly as an uplink pod does. Everything in What both transports share applies to you.
  • Expect the same three-round server-tool cap and the row’s call timeout.

The naming rule still reaches you, just with nothing to warn you about it: a tool named pia_op_anything is filtered out of the chat tool list server-side, and because there is no card there is no podType to declare and no check that will ever mention it. You get a tool that exists, is never called, and produces no error anywhere. Do not use the pia_op_ prefix on this transport.

Outbound credentials run the other way from a pod token — Pia stores them reversibly because it has to replay them — and only this transport can carry one. An auth block on an uplink row is refused rather than ignored. Three schemes exist (bearer, api_key, oauth2_client_credentials); see Outbound authentication for what an administrator fills in, including the six header names you may not ask for.

Scope is server-resolved, always. Every call carries a pia.scope envelope and a pia.user id in the MCP request’s _meta, attached by the server from the authenticated request. Reserved _meta and _pia_* keys are stripped out of model-supplied arguments before dispatch, so a model cannot smuggle scope into a tool call — and they are stripped out of a user’s own assignment input for the same reason.

pia.scope tells you what the server says the scope is. It is not authority. Treat it as the caller’s identity for your own resources, never as permission to reach anything outside them. A pod is the least-trusted party in the system; the guarantees around it hold because it holds exactly one credential and nothing else — no user token, no admin cookie, no database credential, no Temporal credential.

A userId of all zeroes means the envelope said nothing and parsing degraded rather than throwing. Parsing is lenient by design, so checking that is your job.

The normative artefact is the wire schema, not any .NET type. Everything crossing a Mesh boundary is JSON. The package ships three generated files under schemas/ — the card schema, the scope envelope, and the limits and reserved names as plain values — produced from the same constants the server enforces. The blueprint repo carries the same three files, and refreshes them out of the published package rather than by hand-copying, so a limit that changes in the server cannot go on being documented as its old value. If a schema and an assembly ever disagree, the schema wins and the assembly is the bug.

Two tools with the same name resolve first-registered-wins, and the knowledge base is always registered first, so no pod can shadow search_knowledge_base. A later duplicate is hidden with a warning naming both connectors; an ambiguous name is never routed. An administrator can give your row a tool-name prefix to get out of a collision, and the prefix is stripped again on the way back, so your pod always sees its own names.

A failure of yours never fails a chat. A throw becomes an {"error":"connector_unavailable"} tool result the model can answer around. The round still counts against the three-round cap. Malformed annotations coming back from you — citations, sensitivity flags — are ignored with a warning rather than treated as fatal.

Almost every way of getting a pod wrong produces no error. This is the reason the self-check and the offline harness exist, and the list worth keeping beside you:

Symptom Cause
Registers, shows Online, serves nothing The MCP server’s tool collection was left at its null default, so tools/list returns none
A tool is on the card but never callable The card is an advertisement; tools/list is the routable list
A tool exists and the model never calls it Its name starts with pia_op_, so it is withheld from the chat plane
Assignments fail operator_pod_plane_mismatch The card did not declare podType: "operator", or advertises no pia_op_<skill>
Calls hang instead of erroring A hand-rolled uplink transport treating a message-framed connection as newline-delimited
A run ends after one step pia.final absent means final — emit false to continue
Nothing reaches the pod at all The row is granted to no group, or to a group with no members

The admin console will not surface most of these for you. It distinguishes exactly one: a pod whose MCP handling has wedged keeps its connection alive on the transport’s own keepalive and would otherwise read as online for ever, so the console reads the session as well and reports it as Not answering. Even that signal lags, because it learns nothing until a chat request activates connectors — and a pod that cheerfully answers with an empty tool list is not wedged at all. It reads as a healthy Online row, which is the whole problem.

These decide what belongs in a pod at all. None of them is a bug, and none is scheduled to change.

1. Assignment input and artifacts are plaintext in the database

Section titled “1. Assignment input and artifacts are plaintext in the database”

The interactive plane stays end-to-end encrypted. The task plane does not: an assignment’s input and its artifact are stored unencrypted in the server’s database, because whatever runs the work has to be able to read it. The guarantee there is isolation — a user id on every row, one store seam, user-scoped routes, 404 on another user’s assignment, and PostgreSQL row-level security on the two assignment tables where the deployment separates database roles.

What is not claimed, in as many words: nothing stops an operator with database access from reading that plaintext while it is there. The window is Operators:PlaintextRetentionHours — 72 hours by default, and shorter in practice, because a client that acknowledges an assignment triggers the redaction immediately. For a developer choosing what to route through a custom skill, this is the most consequential fact on this page.

A local skill can declare which kinds of user record it accepts and receive them decrypted, within hard caps and behind a consent receipt minted on the user’s own device. A pod-served skill declares nothing and receives only the assignment’s prompt.

That is fail-closed by design, and the reason is worth quoting: there is no server-side class for a pod skill to declare on, the card is an advertisement rather than a grant so it cannot declare either, and the operator row must not — an admin-editable scope declaration is an admin-editable widening of every user’s plaintext exposure. It stays empty until a pod’s declaration has an enforcement point the server owns.

So a pod is the right place for work that needs reach — fetch, transform, compute, talk to a system only your network can see — and the wrong place for work that needs the user’s own records.

3. Pod output re-enters the model without guardrail classification

Section titled “3. Pod output re-enters the model without guardrail classification”

Guardrails classify the inbound request. That decision is made once, before tools are resolved; the tool loop then appends each result to the conversation as a role: "tool" message and calls the model again. Nothing re-classifies those messages.

This is a property of the whole connector plane rather than something pods introduce — the knowledge base is the first connector, and search_knowledge_base results traverse the identical path. Stating it honestly means stating the widening too: the mechanism is the same, but the text now originates in a process a customer wrote rather than in content an administrator ingested. A pod’s tool results are model-visible text you are responsible for.

You cannot do this yourself. An administrator does, and it is one command run before the web host starts — see --provision-pod:

Terminal window
docker compose -f docker-compose.prod.yml run --rm pia-server \
--provision-pod create --name "Invoices" --operator-skill invoices --grant-group <group-id>

That writes the connector row, mints the token, adds the operator row when a skill is named, grants both to the groups given, and prints one JSON object with the token in it. Two things about the result are worth knowing before you ask for it:

  • The token is shown once. The server keeps only a fingerprint and cannot show it again; a lost token is replaced, not recovered. It is valid only against the instance that issued it.
  • A grant to a group with no members is a pod that serves nobody. The command reports the resolved member count for exactly that reason, so ask for that number rather than for confirmation that the grant was written. With no --grant-group at all, a connected pod is granted nothing — that is the intended default.

Servability of the skill you name is not checked at provisioning time: that needs the card, which does not exist until your pod connects. A row can name a skill your pod never advertises, and the first sign of it is a run failing operator_pod_plane_mismatch.

Every step of this leaves an audit trail — Connector.Created, Connector.TokenIssued, Connector.GroupsChanged, plus Operator.Created and Operator.GroupsChanged when a skill was named, then Connector.PodConnected / Connector.PodDisconnected as your pod comes and goes. No credential is ever written to an audit row or a log line; only a short fingerprint prefix is.

  • Pia Mesh — the two planes, the request path, and the trust model.
  • Pod uplink and connector credentials — the authentication boundary, the token, and what a pod can see.
  • Connectors — what an administrator does with your row after you hand them the pod.
  • Operators — pointing an operator row at your pod, and the failure codes that come with it.
  • Configuration — the caps a skill of yours runs inside.