Aller au contenu

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.

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.

  1. Press Create operator, type a name and press Create operator again.

  2. On the detail page, pick the Skill this row serves. Two skills ship: research and brief. New rows start on research. To serve a skill from a pod instead, see Serving a skill from a pod.

  3. Set a Description if you want one, and leave Active on.

  4. 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.

  5. 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 research for 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 skill at 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.

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.

  1. Register the pod as an ordinary connector row and generate its token. A pod authenticates as a connector — there is no separate operator credential.

  2. Have the pod declare podType: "operator" on its pod card and advertise a capability named pia_op_<skill>.

  3. On the operator row, set Pod connector id to the connector row’s id and Skill to <skill> (lower-case, az, 09, _, -).

  4. 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.

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.

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.

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).

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 Queued past QueuedGraceMinutes. The sweep retries it up to MaxReconcileAttempts times, then force-fails it with enqueue_not_started.
  • Started but never finished. The run has closed — or vanished — without writing its terminal state, so the row sits Running indefinitely. The sweep asks the runtime whether a run is still live behind it and force-fails only the ones that are gone, with terminal_write_lost. A live run is left strictly alone. The window here is the run’s own execution budget rather than QueuedGraceMinutes, 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.

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.

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.

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.

  • The per-user cap refuses, it does not queue. MaxConcurrentAssignmentsPerUser bounds what a user may have in flight, but a request over the ceiling is answered 429 and 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 get 429.
  • A Queued row 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 most QueuedGraceMinutes × 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_deauthorizednot 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.