Operators
Ce contenu n’est pas encore disponible dans votre langue.
The Operators page (/admin/operators) manages the task plane of Pia Mesh. An operator runs assignments: durable, multi-step work that outlives the request that started it, reports progress, produces an artifact, and can be cancelled.
Reading the runtime tiles
Section titled “Reading the runtime tiles”The list page shows two tiles, and an operator’s own page shows four:
| Tile | Meaning |
|---|---|
Operator runtime / Operators:Enabled |
Whether the startup gate is on. Restart-only. |
| Runtime availability | Whether an assignment can be enqueued right now. Unavailable means the gate is off or the Temporal address is blank. |
| Temporal address | Where the embedded worker connects. |
| Task queue | The Temporal task queue this server serves. |
None of these dial Temporal — they report configuration, not reachability. An address that is set but unreachable still reads as available here; what you will see instead is the worker logging a warning and retrying every 30 seconds, and assignments sitting in Queued.
Temporal reachability is deliberately kept off /health. An unreachable Temporal must never turn into a restart loop and a chat outage for a feature that defaults off.
Registering an operator
Section titled “Registering an operator”-
Press Create operator, type a name and press Create operator again.
-
On the detail page, pick the Skill this row serves. Two skills ship:
researchandbrief. New rows start onresearch. To serve a skill from a pod instead, see Serving a skill from a pod. -
Set a Description if you want one, and leave Active on.
-
Under Group access, check the groups whose members may enqueue assignments for this operator. As with connectors, this is the only place the grant is made — the group editor’s plugin picker cannot see operator rows.
-
Press Save changes.
A row selects a skill; its name is only a label
Section titled “A row selects a skill; its name is only a label”The Skill field is the value callers pass as skillName to POST /api/assignments. The row’s Name is a display label for this UI and reaches nothing else — calling a row “Deep Research Bot” does not make "Deep Research Bot" a valid skillName.
Two skills ship, and the Skill column on the list page shows what each row serves:
| Skill | Steps | Mode | Accepts as input | What it does |
|---|---|---|---|---|
research |
1 | Research |
A prompt, plus chats, sessions and memories | A retrieve–reason–write pass that answers in one step |
brief |
3 | Assistant |
The same, plus todos | Survey, then outline, then compose a written brief |
Both run in an assistant-class mode and can therefore ground themselves in the knowledge base. A row picks one of them; there is no way to configure a skill’s step count, its mode, or what it accepts as input.
That last column is a security boundary, not a feature list. A client may only send records of a kind the skill itself declares, and the server refuses the rest — so a skill’s reach over a user’s data is fixed in code and reviewed like code. It is deliberately not editable here: an admin-editable scope declaration would be an admin-editable widening of every one of that skill’s users at once. A pod-served skill declares nothing and receives a prompt alone, since a pod card is an advertisement rather than a grant and cannot declare on Core’s behalf.
Three rules govern the mapping:
- One row, one skill; one skill, one row. If two granted rows select the same skill, the older row serves it and the newer one contributes nothing. The server logs a warning naming both. Delete or re-point one of them. This spans both kinds of row — a pod-served row and a built-in one cannot both claim
research. - A row that names an unknown skill is skipped, not defaulted — silently serving
researchfor a row you pointed elsewhere would be worse than doing nothing. The other rows keep working. The admin page refuses an unknown skill before it can be saved, so this only happens to a hand-edited row. The same applies to a row whose configuration is invalid rather than merely absent: it is skipped, never defaulted. - No granted, active row means no skill. A caller whose group has none gets
400 Unknown skillat enqueue, not a permissions error — from the API’s point of view the skill simply is not there.
The chat mode is declared by the skill in code, not by the row and not by the caller. There is no mode setting here, deliberately: a wrong mode fails silently — the run loses knowledge-base grounding, produces an ungrounded artifact, and still bills the tokens.
A multi-step skill costs more than one step
Section titled “A multi-step skill costs more than one step”brief runs three passes, each a full model round trip. Two things follow.
MaxSteps must be at least as large as the longest skill a group can reach. Set it below that and every run of that skill fails with operator_max_steps_exceeded after paying for the steps it did take — the cap is doing what it is for, but the misconfiguration is yours, not the caller’s. The default of 8 leaves room.
Spend accumulates across passes against one PerAssignmentTokenCeiling. A research run costs roughly 20k tokens; a brief run costs about three times that. Both sit well inside the 200k default, but the ceiling is now a control that can actually bind rather than a number nothing approaches.
Serving a skill from a pod
Section titled “Serving a skill from a pod”A skill does not have to be one of the two built into the server. It can live in a pod your own developers wrote — Writing a pod is their side of what follows. Fill in Pod connector id with the id of the connector whose uplink reaches a pod, and the Skill field stops being a picker and becomes free text: it now names a capability on that pod, and the server dispatches each step there instead of running a model call of its own.
-
Register the pod as an ordinary connector row and generate its token. A pod authenticates as a connector — there is no separate operator credential.
-
Have the pod declare
podType: "operator"on its pod card and advertise a capability namedpia_op_<skill>. -
On the operator row, set Pod connector id to the connector row’s id and Skill to
<skill>(lower-case,a–z,0–9,_,-). -
Grant the operator row to a group. That grant is the authorisation — the connector row’s own group grant is irrelevant here, and a pod whose connector row is granted to nobody is still a perfectly good remote operator.
What a remote skill can and cannot do
Section titled “What a remote skill can and cannot do”A remote skill buys assignment durability, progress events, retries and cancellation around non-model work: fetch, transform, compute, reach a system only the pod can get to. Every model call in a run stays in the server.
That is a constraint, not an omission. A pod calling a provider with its own key is spend the server cannot see — no usage row, no quota precheck, no guardrail gate — and a token count reported by a pod is untrusted input that would make PerAssignmentTokenCeiling enforce a number the pod chose. So a remote step contributes zero tokens to the assignment, and a pod cannot call back through the server’s own chat surface: that would need a user credential, which a pod never holds.
A remote skill also receives only the assignment’s prompt. It gets no decrypted items of any kind, because nothing about the row can widen that: the pod’s card is an advertisement rather than a grant, and an admin-editable scope on this page would be an admin-editable widening of every user’s exposure.
Failure codes, and which ones are retried
Section titled “Failure codes, and which ones are retried”The distinction that matters operationally is between the pod is not there right now and this configuration cannot work:
errorCode |
Retried? | Means |
|---|---|---|
operator_pod_unavailable |
Yes | The pod is not connected, no session could be established, or the call failed or timed out. A pod bouncing for ten seconds costs one attempt, not the assignment |
operator_pod_row_unavailable |
No | The connector row this operator points at is missing, inactive, soft-deleted, not an uplink row, or its configuration no longer parses |
operator_pod_plane_mismatch |
No | The pod is connected but its card did not declare podType: "operator". Dispatch fails closed — a card that omits podType means connector |
operator_pod_step_failed |
No | The pod answered with an error. It is reachable, so this is its own outcome and retrying would only delay the terminal row |
operator_pod_row_unavailable has its own code for a reason worth knowing: deactivating or deleting a pod’s connector row does not touch the operator row’s grant, the two are separately revocable, and the resulting failure would otherwise read as “pod absent” or as a model failure.
Supervising assignments
Section titled “Supervising assignments”There is no assignments grid in the admin UI. Two admin API routes cover supervision:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/assignments?skip=&limit= |
Cross-user roll-up — status, step count, tokens spent, timestamps, error code |
POST |
/api/admin/assignments/{id}/terminate |
Force a stuck assignment to a terminal state |
Both roll-up and single-assignment reads carry two token counters. tokensSpent is what the run’s own steps recorded; tokensAbandoned is spend from a step that was still in flight when the run ended and whose result was therefore refused. An aborted assignment’s true cost is the sum — see the API reference.
The roll-up deliberately carries no artifact and no error message. Both are free-text model output, and this list spans users; a roll-up needs status and counters. The error code is included, because it is a fixed server-minted value (operator_token_ceiling_exceeded, enqueue_not_started, terminal_write_lost, and so on). A user’s own artifact is served only by the user-scoped GET /api/assignments/{id}.
Terminate is the escape hatch for a wedged run, or for a row whose workflow never started. It tolerates a missing or unreachable workflow and force-writes the terminal state when there is still a row to write. It always answers 200 with { "changed": true|false } — false means the row does not exist or was already terminal, which is a deliberate no-op, not an error. An optional { "reason": "..." } body is recorded on the row and truncated at 1024 characters rather than rejected.
A terminated assignment is distinguishable from an ordinarily failed one only by its event log: both land on Failed, but the event kinds differ (terminated versus failed).
Retention
Section titled “Retention”Finished assignments and their events are deleted after Operators:RetentionDays (default 30). A background sweep runs every five minutes and also recovers rows whose run left them stranded. It has two arms:
- Never started. The row was written but its workflow never began, so it sits
QueuedpastQueuedGraceMinutes. The sweep retries it up toMaxReconcileAttemptstimes, then force-fails it withenqueue_not_started. - Started but never finished. The run has closed — or vanished — without writing its terminal state, so the row sits
Runningindefinitely. The sweep asks the runtime whether a run is still live behind it and force-fails only the ones that are gone, withterminal_write_lost. A live run is left strictly alone. The window here is the run’s own execution budget rather thanQueuedGraceMinutes, because a legitimately long run must never be mistaken for a stranded one.
Without the second arm a stranded Running row would be permanent: it is not Queued, so the first arm skips it, and it has no completion timestamp, so the retention sweep never deletes it. It would also keep consuming one of the user’s MaxConcurrentAssignmentsPerUser slots for ever.
Plaintext goes long before the row does
Section titled “Plaintext goes long before the row does”Assignment input and artifacts are plaintext in the database. That is a deliberate part of the Mesh trust model: the interactive plane stays end-to-end encrypted, the background plane trades encryption for tenant isolation — user-scoped routes, one store seam, and PostgreSQL row-level security on the assignment tables. What bounds the exposure is a much shorter window than RetentionDays:
| Tier | When | What goes |
|---|---|---|
| The client acknowledges | POST /api/assignments/{id}/collect, once it has stored its own copy |
Input, artifact, errorMessage, and every event message the server did not author. A redacted event records it |
| Nobody acknowledges | Operators:PlaintextRetentionHours (default 72) after the run finished |
The same, from the same sweep that recovers stranded rows |
| The row itself | Operators:RetentionDays (default 30) after the run finished |
The row and its events are deleted outright |
The second tier is the one the guarantee rests on: it does not depend on a device ever coming back. What survives into the third window is the row’s metadata — status, step count, token spend, error code — with no plaintext in it.
Three knobs, so the numbers a user is told are the numbers configured here. PlaintextRetentionHours at or below zero falls back to 72 rather than meaning “immediately” — an aggressive-looking setting must not be a way to lose an artifact before its client has polled for it.
Licensing
Section titled “Licensing”The operator runtime and the assignment API both require the AiProxy license feature. Without it, the worker does not start, and POST /api/assignments is refused.
Audit trail
Section titled “Audit trail”| Event | Written when |
|---|---|
Operator.Created / Operator.Updated / Operator.Deleted |
A row is created, saved, or deleted — Operator.Updated records the skill |
Operator.GroupsChanged |
The group grants are replaced |
Assignment lifecycle transitions are not audit-log events — they are assignment_events rows, served by the assignment API.
Known gaps
Section titled “Known gaps”- The per-user cap refuses, it does not queue.
MaxConcurrentAssignmentsPerUserbounds what a user may have in flight, but a request over the ceiling is answered429and dropped — nothing is held or paced on their behalf, and retrying is the caller’s job. The ceiling itself is exact even under a parallel burst: the decision is made inside the same PostgreSQL transaction that inserts the row, serialized per user, so a burst of simultaneous requests admits exactly the cap and the rest get429. - A
Queuedrow holds a slot. Rows counted toward the cap include ones waiting on the runtime, so while Temporal is unreachable a user’s stuck assignments can occupy their own quota until the reconcile pass force-fails them — at mostQueuedGraceMinutes×MaxReconcileAttempts. - The de-authorisation overlap is warned about, not prevented. Saving an operator row whose pod connector row is granted to some of the same groups now answers with a warning naming how many groups overlap — and still saves, because the overlap is a legitimate thing to configure and the cost is a lost run on a pod restart rather than a security hole. Nothing enforces it: a run started for a user in an overlapping group still fails
operator_deauthorized— not retried — if the pod bounces mid-run. The warning makes the configuration visible; only changing it makes the failure go away. It is also pre-existing and true of the built-in skills, so refusing the configuration here would refuse something that has always been allowed.