Status: active
Action catalog for the system extension's MCP surface. Lists every system_*, system_sdwan_*, kubernetes_*, and docker_* action callable via the Powernode MCP server.
Audience: AI Concierge prompt authors, external operators integrating with the platform's MCP server, contributors adding new actions.
Architecture: The MCP registry (action-name → tool-class mapping) lives in the parent platform at
server/app/services/ai/tools/platform_api_tool_registry.rb. The tool class implementations live in the extension atextensions/system/server/app/services/ai/tools/. Rails autoloading resolves the class names across both locations.
flowchart LR
subgraph Parent["Parent platform"]
Reg["platform_api_tool_registry.rb"]
M1["system_* → Ai::Tools::SystemFleetTool"]
M2["system_sdwan_* → Ai::Tools::SdwanTool"]
M3["kubernetes_* → Ai::Tools::Kubernetes*Tool"]
M4["docker_* → Ai::Tools::Docker*Tool"]
Reg --- M1
Reg --- M2
Reg --- M3
Reg --- M4
end
subgraph Ext["System extension"]
T1[system_fleet_tool.rb]
T2[sdwan_tool.rb]
T3[kubernetes_*_tool.rb]
T4[docker_*_tool.rb]
Svc[/services/system/<br/>*_service.rb<br/>lifecycle + biz logic/]
T1 --> Svc
T2 --> Svc
T3 --> Svc
T4 --> Svc
end
MCP[(MCP client<br/>call to action)] --> Reg
M1 -. "class-name string<br/>resolved by autoloader" .-> T1
M2 -.-> T2
M3 -.-> T3
M4 -.-> T4
To add a new MCP action: register the action-name → class-name mapping in the parent's registry file, then implement the action method in the corresponding tool class inside the extension. Both repos commit; bump the submodule pointer.
To regenerate the parent's full tool catalog with parameter schemas:
cd server && bundle exec rails mcp:generate_tool_catalog
# → writes to docs/platform/MCP_TOOL_CATALOG.md (gitignored)This document is the system-extension subset of that catalog — manually curated, scannable for operator workflows.
Every action requires a permission grant on the calling user/agent. Permissions follow the schema <resource>.<verb> (e.g., system.nodes.read, system.modules.write). The mapping lives in server/db/migrate/*_permissions.rb (parent platform). Agents have permissions assigned via Ai::AgentPermission.
system_* — Fleet, lifecycle, modules, storage, architecture, GitOps, CI workers, disk image CI, providers, topology, ingress, GPU/inference, agent-fleet, A2A (134 actions)
Backed by Ai::Tools::SystemFleetTool (parent-registered, extension-implemented) plus the managed-Docker, package-repository, architecture-catalog, disk-image, ingress, storage-owner, and agent-fleet tool classes. The registry routing lives in the parent (platform_api_tool_registry.rb); the tool-class implementations live in the extension under server/app/services/ai/tools/.
| Action | What it does | Audience |
|---|---|---|
system_list_nodes |
List Node rows, one page at a time (limit / cursor). It declares exactly two FILTERS, template_id and environment — there is no status and no lifecycle_class filter, and an undeclared key is dropped silently, so filtering by one returns the unfiltered list with no error. environment fails closed: a plane this account does not have is an error, never a silent whole-fleet answer |
operator, agent |
system_get_node |
Fetch a Node by id | operator, agent |
system_create_node |
Create a Node row (no provider VM yet) | operator, agent |
system_list_instances |
List NodeInstance rows | operator, agent |
system_get_instance |
Fetch NodeInstance details (status, last_heartbeat, running modules) | operator, agent |
system_provision_instance |
Trigger provider VM creation; creates Task; returns NodeInstance | operator, agent |
system_terminate_instance |
Destroy provider VM; cascade-FK cleanup | operator, agent |
system_replace_instance |
DR: move an unrecoverable instance's volumes, SDWAN membership and VIPs onto a warm pool member. Approval-gated (system.instance_replace); terminates nothing — pass reap: true for a SECOND, separately-gated terminate. Refused for a target still running and still reporting unless accept_running: true; denied outright to MCP instance principals by the deny overlay (*replace_instance*), so reap: true cannot be smuggled through it |
operator |
system_reap_instance |
DR: terminate the unrecoverable instance whose workload system_replace_instance has already moved. Approval-gated (system.instance_reap); denied outright to MCP instance principals by the deny overlay |
operator |
system_drift_report |
Compare running module digests vs assigned modules. Returns missing / extra / mismatched (each with a *_count; any sets drift: true) plus unverifiable / unverifiable_count — assigned modules whose served version has no oci_digest, so the running digest cannot be compared; they do not set drift and are never extra |
operator, agent |
| Action | What it does |
|---|---|
system_list_templates |
List NodeTemplates |
system_get_template |
Fetch a Template + its module assignments |
system_update_template |
Edit any field system_create_template accepts: name, description, enabled, public, node_platform_id, admin_user, config (config replaces the stored hash) |
system_assign_module_to_template |
Add a module to a Template, with the join's own priority, enabled, config and recommends_override. (config is stored verbatim and is delivered by whatever consumes it; target_cluster_id on a k3s-agent assignment is delivered to the agent as of IMP-a5f236e8cc56, see CONTAINER_RUNTIMES.md.) Refuses error-severity composition conflicts; enabled: false stages the assignment without shipping it and skips the conflict check |
system_update_template_module |
Edit an existing assignment in place (priority/enabled/config/recommends_override). enabled: false is the reversible way to remove a module — the join survives and its derived NodeModuleAssignments stay until a purging apply; unassigning destroys the join and purges them at once. Re-enabling runs the conflict check |
system_list_node_module_assignments |
List a Node's NodeModuleAssignments (paginated, limit / cursor): module id + name, enabled, priority, source_template_module_id (null = hand-authored) and timestamps. Only enabled rows are served to the node's agent. Requires system.modules.read |
system_assign_module_to_node |
Create a NODE-level assignment — how a node gains a module its template does not name. Refuses a module disabled in the catalog. Refuses a module with a hard (required, transitive) dependency the node neither has assigned-and-enabled nor gets from its template closure, or that is catalog-disabled, naming the missing modules — nothing is auto-assigned; assign them first. Refuses a module already assigned to the node (enabled or not — use system_update_module_assignment), and refuses error-severity composition conflicts against the node's enabled assignments, whatever enabled is; the dependency check likewise (the toggle runs neither, so create-disabled-then-enable would otherwise bypass them). Only DECLARED conflicts are seen. The row is hand-authored (source_template_module_id null), so template reconciliation never reaps it. Nothing is pushed: the agent picks it up on its next module sync. Requires system.modules.update |
system_list_modules |
List NodeModules |
system_get_module |
Fetch a Module + its categories + dependencies |
system_list_module_versions |
List versions of a module |
system_promote_module_version |
Promote a version INTO an environment: writes that pinned plane's pin, one rung at a time, gated in the target plane. Moves that plane's pin only — it does not change which version the FOLLOWING planes serve (NodeModule#current_version_id), which a publish moves. Use system_rollback_module_version with environment: to walk one pin back, or without it to repoint the fleet |
system_validate_module_manifest |
Dry-run validation of a manifest.yaml payload against the manifest schema before publishing |
| Action | What it does |
|---|---|
system_list_tasks |
List Tasks (filter by node_id, instance_id) — newest first, one page at a time (limit / cursor, with count the uncapped total); there is no status or type filter |
system_get_task |
Fetch one Task (account-scoped): command, status, progress, operable handle, timestamps and error_message (full stored failure reason, 16 KB cap, redacted of credential-shaped tokens). include_events: true adds its events — see "Task events" below; wait_seconds long-polls, see "Waiting instead of polling" |
system_get_task_log |
One bounded page of a Task's FULL stored log (a module build's whole scrubbed output, not just the log_tail on its events) — see "Full task log" below. SELF-ONLY for an instance principal |
system_cancel_task |
Cancel a pending or in-flight Task |
System::Task has no result column, so a handler's result rides the task's events (events[].result). A module build's scrubbed log_tail is there on success as well as failure, and until include_events nothing on the tool surface served it. system_get_task with include_events: true adds three keys to the reply: events (the newest 20, oldest first, each with its type, message, timestamp, data and any result), events_total and events_truncated (true when older events were dropped). Absent or false the reply is unchanged; a value that is not true or false (or those two strings) is refused, naming the parameter. It composes with wait_seconds: the events are those of the final snapshot. Account scoping is the same as the plain read, and it needs no permission beyond system.infra_tasks.read.
Every string in the events, hash keys and log_tail included, goes through the same System::ShellOutputSanitizer.redact_text pass as error_message (mechanism and size policy in System::StoredOutputRedactor), so a credential-shaped token planted anywhere in an event comes back as [REDACTED]. Redaction always runs before any cut and the emitted slice is checked once more, so no fragment of a secret survives truncation. The sanitizer's patterns key on what precedes a secret, so structure is handled explicitly and fails closed:
- A string under a hash key is redacted together with the key. If redaction rewrote the key itself, the value is withheld whole.
- The strings of an array (nested arrays flattened, numbers and booleans stringified) are checked as one string, joined by a space and again by a newline: an argv, a PEM split into lines or a
.netrcline split into words. If anything in it is redacted, every string of the array, nested ones included, is withheld, because a redaction cannot be reliably mapped back onto elements. A log-line array containing one credential therefore loses all its lines. An array too large to check (over 500 strings or 256 KB) is withheld the same way. A tail-bounded array (under alog,stderr... key) whose retained items start with PEM body lines drops them, since their BEGIN line was cut away. - A key that names a secret withholds its whole subtree (a string, a number, a nested object or an array alike). Matching is case-insensitive over the snake-cased key: as a substring for
password,passwd,passphrase,secret,token,api_key,access_key,private_key,credential,authorizationand the rest ofAi::SensitiveParams.key_patterns(which includes any deployment-configured additions), and as a whole segment forpass,pwd,pin,otp,totp,passcode,mfa,auth,cookie,sessionandsignature, sobypassandauthorare left alone. Any key ending inkeyorkeys(master_key,ssh_keys, a barekey) is withheld too, exceptsort_key,cache_key,primary_key,foreign_key,idempotency_key,partition_keyandpublic_key. A number under a*_count,*_ttl,*_secondsand similar key (token_count) is kept. When in doubt it redacts.
The payload is bounded on every axis:
- Strings: 16 KB each, the truncation marker included. A
log_tail(and any key named log, tail, stdout, stderr, output or trace) keeps its end and marks the cut at the front ([truncated]...); every other string keeps its start (...[truncated]). - Budget: 128 KB in all, charged for strings, keys, numbers and markers alike and spent newest-first. Once it is gone the rest of a container collapses into one
[omitted ...]entry, and the older events into one leading[omitted: N older events ...]element. - Structure: at most 5000 values per reply, 6 levels of nesting, 50 keys or items per container, and keys are capped at 200 characters. Two keys that come out the same after redaction or truncation stay apart as
<key>and<key>#2.
error_message itself is unchanged: head-bounded, marker added after the limit. Redaction is best-effort, the sanitizer's usual coverage: a secret shape with no pattern, under a key with no secret name, is not caught.
A module build's log_tail keeps only the last 4 KB of stdout and 128 KB of stderr, so a failure whose cause scrolled out of that window could not be diagnosed over MCP. The agent now also uploads the WHOLE scrubbed build log (POST node_api/status/tasks/:id/log, on the failure path as well as success), scrubbed on the node with the same secret scrub and private-key removal as log_tail, capped at 1 MiB with the END kept; truncated and original_bytes say when and by how much it was cut. The upload is best-effort and never fails a build. Only a task the instance itself owns, and only once it is running or finished, accepts a log.
The platform stores it in system_task_logs (one row per task, replaced by a re-upload), redacts it through System::StoredOutputRedactor when it is written AND again over the whole text on every read (so a secret split across a page boundary is redacted whole, and a pattern learned later covers older rows), and keeps it for 14 days by default (system.task_log.retention_days); expired rows are neither served nor kept, and an instance's expired rows are pruned on each of its uploads.
system_get_task_log returns one page: content, offset, next_offset, has_more, total_bytes, truncated, original_bytes, expires_at. Pass next_offset back as offset until has_more is false; offsets are byte offsets on a character boundary, so the pages concatenate to the whole log. limit defaults to 64 KiB and is clamped at 256 KiB. A task with no stored log (never uploaded, or expired) is an error saying so; a task that does not exist, is in another account, or (for an instance principal) belongs to another instance is the same not-found error. Only ci.module_build uploads a log today: ci.package_build and ci.lint_discovery still carry only their log_tail.
| Action | What it does |
|---|---|
system_list_instance_pools |
List InstancePools |
system_get_instance_pool |
Fetch a pool + its members |
system_create_instance_pool |
Create a new pool (target_size, min, max, region, type, template). Approval-gated (system.instance_pool_create) — returns pending: true + a deferred_operation_id and no pool exists until approved |
system_update_instance_pool |
Tune a pool (sizes, status, region/type, metadata). Partially approval-gated: a target_size/max_size INCREASE on system.instance_pool_ceiling_raise and status: "archived" on system.instance_pool_archive return pending: true + a deferred_operation_id and write nothing until approved; decreases, min_size, description, regions, metadata and status active/paused/draining apply inline |
system_drain_instance_pool |
Stop replenishing (enforced — replenish! refuses a non-active pool) + terminate ready members; claimed members keep running. Reversible: status: "active" warms the pool back to its standing target_size |
system_delete_instance_pool |
Remove a drained pool (refuses while members are still attached) |
system_acquire_pooled_instance |
Atomic claim of a ready member |
system_return_pooled_instance |
Return a claimed member to the pool (reaper resets state) |
system_replenish_instance_pool |
Manual trigger of the reaper for this pool |
| Action | What it does |
|---|---|
system_provision_docker_runtime |
Phase 1 Docker daemon provisioning on a NodeInstance |
system_decommission_docker_runtime |
Destroy managed DockerHost row + Vault TLS material |
system_mark_docker_ready |
Agent-side ack endpoint (mostly internal) |
system_list_managed_docker_hosts |
List Powernode-managed Docker hosts (excludes externally-registered) |
Backed by Ai::Tools::SystemPackageRepositoryTool. Manages apt/rpm package repositories, browses the synced catalog with rich filtering, and materializes packages into NodeModules with full dependency closure.
| Action | What it does | Audience |
|---|---|---|
system_list_package_repositories |
List accessible apt/rpm repositories (account-scoped + shared); filter by kind and node_platform_ids[]. Each row carries embedding_pending_count for catalog coverage. |
operator, agent |
system_get_package_repository |
Fetch one repository with detail (apt_config, rpm_config, linked NodePlatforms) | operator, agent |
system_create_package_repository |
Register a new apt/rpm/dnf repository (visibility: shared requires system.package_repositories.manage_shared) |
operator |
system_update_package_repository |
Update repository config (architectures, enabled, priority, apt/rpm config) | operator |
system_delete_package_repository |
Delete a repository (only when no modules link to its packages) | operator |
system_sync_package_repository |
Trigger an immediate upstream sync (fetches index, upserts Package rows). Enqueues SystemPackageEmbeddingJob after successful sync. |
operator, agent |
system_link_repository_platform / system_unlink_repository_platform |
M:N link/unlink between repository and NodePlatform | operator |
system_search_packages |
Search the synced catalog. Hybrid trigram+embedding ranking (default), pure lexical, or pure semantic. Filters: q, mode (lexical/semantic/hybrid), repository_ids[], kind, architectures[] (canonical, cross-kind expanded), sections[], license, provides (capability lookup), sort, page, per_page. Back-compat with singular repository_id/architecture/section. Response includes similarity (when mode ≠ lexical), provides_names, license, applied_filters echo. total is null under semantic/hybrid (exact COUNT prohibitive on vector-filtered sets). |
operator, agent |
system_discover_packages |
Intent-based semantic discovery — describe a capability ("reverse proxy", "distributed cache") and get ranked packages. Pure cosine-distance ranking via pgvector. Inputs: intent (required), repository_ids[], kind, architectures[], license, top_k (1-50, default 10). Returns {results: [{package_id, name, version, architecture, summary, similarity, repository_id, reason}], seed_count, confidence (high/medium/low)}. Use system_search_packages instead when you already know the package name. |
operator, agent |
system_get_package |
Fetch one Package row with full metadata (depends, recommends, provides, conflicts, license, maintainer) | operator, agent |
system_resolve_package_dependencies |
Preview the dependency closure of a package without writes — required + recommends candidates the operator can opt into | operator, agent |
system_create_module_from_package |
Materialize a package + transitive deps as NodeModule rows + ModuleDependency edges; dispatches CI build per architecture | operator, agent |
system_list_package_module_links |
Auditable provenance — which NodeModules came from which packages, top-level vs auto-generated | operator, agent |
system_refresh_package_module |
Re-materialize a NodeModule when upstream drifts (replays persisted recommends_chosen for deterministic refreshes) |
operator, agent |
system_suggest_architectures_for_fleet |
T2.B — fleet-aware architecture suggestion for materialization; intersects repo's archs with NodePlatform coverage | operator, agent |
Embedding pipeline: Package.embedding (pgvector 1536-dim) is populated by SystemPackageEmbeddingJob (worker-side). The job is auto-enqueued after every sync that upserts ≥1 row, and can be manually run via rake system:packages:backfill_embeddings. Without embeddings, hybrid mode contributes only the trigram leg — search still works, just without semantic ranking.
Permission map:
system.package_repositories.{view,create,update,delete,sync,manage_shared}— repository CRUD + syncsystem.packages.{view,search}— catalog read access (search covers bothsearchanddiscover)system.package_modules.{view,create,refresh}— materialization + provenancesystem.packages.embed(worker-only) — embedding writeback
| Action | What it does | Audience |
|---|---|---|
system_list_architectures |
List System::Architecture rows in the account catalog |
operator, agent |
system_get_architecture |
Fetch one row with its package list + capability tags | operator, agent |
system_create_architecture |
Create a new architecture (named composition of packages + capabilities) — autonomy-gated via system.architecture.create policy |
operator, agent |
system_update_architecture |
Edit packages / capability tags on an existing architecture | operator, agent |
system_delete_architecture |
Remove an architecture; refuses if any Node references it | operator, agent |
system_propose_architecture |
Operator-facing review surface — propose a new architecture for human approval before catalog insertion | operator, agent |
Permissions: system.architectures.{view,create,update,delete,propose}
| Action | What it does | Audience |
|---|---|---|
system_gitops_list_repositories |
List the account's registered repositories — over MCP, the only way to discover their ids | operator, agent |
system_gitops_get_repository |
Read one repository's configuration + credential contract (vault_credential_path, required_credential_keys — path and key NAMES, never values) |
operator, agent |
system_gitops_register_repository |
Register a Git repository as a fleet-state source (URL + branch + auth ref). Approval-gated under system.gitops_register_repository (Ai::AutonomyGate, replayed by Ai::Executors::DeferredToolCall): when policy requires approval it answers pending: true and no repository exists until an operator approves. Parameters are validated before the request is queued, so a rejected repo_url still fails inline |
operator, agent |
system_gitops_sync_repository |
Trigger a sync (fetch + parse fleet.yaml + compute diff against effective state). A FAILED reconcile answers success: false with the reason in error (sync_run_id still under data); on a standby control plane it refuses with refusal_code: "standby_control_plane" and creates no run |
operator, agent |
system_gitops_get_sync_run |
Fetch one sync run with the diff payload + proposed change set | operator, agent |
system_gitops_get_drift_report |
Per-repository drift summary (rows that diverge from declared state) | operator, agent |
system_gitops_apply_proposal |
Apply a proposed change set — the proposal must already be approved, AND the call is approval-gated under system.gitops_apply_proposal (Ai::AutonomyGate, replayed by Ai::Executors::DeferredToolCall): when policy requires approval it answers pending: true and applies nothing until an operator approves |
operator, agent |
Permissions: not one family — read the map in SystemFleetTool::ACTION_PERMISSIONS, not this
heading. system_gitops_get_repository / system_gitops_list_repositories gate on
system.gitops.read (IMP-b1191457a091 — the same name their REST twin uses, because these two
return the vault_credential_path projection). system_gitops_get_sync_run /
system_gitops_get_drift_report gate on system.modules.read; system_gitops_register_repository
/ system_gitops_sync_repository / system_gitops_apply_proposal gate on
system.modules.update. There is no system.gitops.view / .apply permission — the registered
names are read / write / sync / reconcile (server/lib/powernode_system/engine.rb).
| Action | What it does | Audience |
|---|---|---|
system_list_disk_image_publications |
List publications for a NodePlatform (filterable by status / arch / git_sha) | operator, agent |
system_list_disk_image_webhooks |
List webhook rows (per-pipeline; the URL embeds the webhook UUID). Carries no secret_preview (IMP-27cc7dceb97b) — the operator UI reads that from GET /api/v1/system/disk_image_webhooks, which gates on system.disk_image_webhooks.read rather than this action's system.modules.read, so it is not a drop-in fallback for every caller |
operator, agent |
system_set_default_disk_image_publication |
Flip the platform's default-boot publication to a different row | operator, agent |
system_set_disk_image_retention |
Set per-NodePlatform retention_count (count-based; 7-day grace fixed) |
operator, agent |
system_list_disk_image_publications (also exposed inline) |
Operator-curated listing for the UI's publications panel | operator |
Permissions: system.disk_image_publications.{view,set_default,retain}, system.disk_image_webhooks.view
See DISK_IMAGE_CI.md for the operator-facing workflow that uses these actions in concert with bootstrap_disk_image_ci + provision_disk_image_webhook.
system_dispatch_module_build_batch, system_get_module_build_batch and system_cancel_module_build_batch are described in the machine catalog. The recovery verb:
| Action | What it does | Audience |
|---|---|---|
system_readvance_module_build_batch |
Re-advance ONE batch whose member build finished while its entry is still dispatched (stalled: true on system_get_module_build_batch) — the lease sweep's readvance backstop, on demand, for when the hub-worker cron that normally runs it is down. Calls the sweep service itself, so it does exactly what the sweep would: a completed build is signed + published (and promoted, unless shadow or withheld), a failed one retried or failed, and still-queued members of the batch dispatched. Like the unattended sweep, it publishes and promotes without the release-promote autonomy gate — deliberate, since it is the sweep's own step run on demand; the kill-switch and control-plane gates still apply. Refused under the account kill switch or on a standby control plane; a batch with nothing stalled, not in dispatched/awaiting_signature/publishing, or already being advanced is a no-op (readvanced: false + reason). Requires system.module_builds.readvance (admin / owner / manager, like system.module_builds.cancel) |
operator |
Three read-only long-polls replace polling the database. system_get_task and system_get_module_build_batch take wait_seconds; system_wait_for holds a rollout.
| Action | What it does | Audience |
|---|---|---|
system_get_task + wait_seconds |
Holds until the task is finished? (complete, failed, aborted or cancelled) |
operator, agent, instance |
system_get_module_build_batch + wait_seconds |
Holds until the batch is finished? (complete, partial, failed or cancelled) |
operator, agent, instance |
system_wait_for (module_version_id, environment) |
Holds until every running node in the environment that carries the module reports the version's oci_digest in its heartbeat running_module_digests; an environment with no such node is not converged. Returns converged, instance_count, converged_count and the pending nodes with the digest each runs |
operator, agent, instance |
wait_seconds is clamped to the server cap (SystemFleetTool::WAIT_MAX_SECONDS, 45s) and polls every 2s; a value that is not an integer is refused, naming the parameter. The cap is a conservative margin, not a measured limit: nothing in this repo times out the endpoint, but the shortest MCP read timeout in code is the 60s of the platform's outbound legacy HTTP client (its streamable client uses 300s), and a hop in front of the platform is not visible from here. An expired wait is a success carrying timed_out: true and the current snapshot, never an error; call again to keep waiting. Each wait holds one Puma thread, so at most a quarter of RAILS_MAX_THREADS (WAIT_CONCURRENCY, 4 by default) may wait at once per process; past that a call that is not already done is answered with a single check, timed_out: true and wait_degraded: true. Absent or 0, the two getters answer at once with their old reply. system_wait_for defaults to the cap, and 0 checks once; a node that reports the digest but has gone silent (no heartbeat within NodeInstance::HEARTBEAT_STALE_AFTER) is listed as pending with stale: true, not counted converged. All three need only their read permission (system.infra_tasks.read, system.module_builds.read, system.modules.read); an instance principal reaches them by an MCP tool grant naming them (system_grant_instance_mcp_tools), since none is destroy-shaped.
system_inspect_node diagnoses one node through its own on-node agent, with no shell. It is the routine sibling of system_out_of_band_exec (human-only, approval-gated, ssh) and shares no code path with it. It queues a probe.node_inspect task running one of seven fixed collectors and long-polls for the result.
collector |
Runs (exact argv) | Arguments |
|---|---|---|
wg_status |
wg show <interface> (never dump, never all; private and preshared key lines are dropped) |
interface |
routes |
ip vrf show; ip -4 route show table all; ip -6 route show table all; ip -4 rule show; ip -6 rule show |
none |
nft |
nft list ruleset or nft list chains |
scope (ruleset default, chains) |
journal |
journalctl -u <unit> -n <lines> -r --no-pager -o short-iso (-r: newest first, so a bounded read keeps the newest; returned oldest-first) |
unit, lines (1-500, default 100) |
unit |
systemctl --no-pager cat <unit> (unit file and drop-ins) |
unit |
caps |
systemctl show -p MainPID --value <unit>, then the Cap* and NoNewPrivs lines of that PID's /proc status, decoded to names |
unit |
file_stat |
no command: type, size, mode, owner, mtime and sha256 of a file, never its contents | path |
A collector takes only its own arguments; another collector's argument is refused, not ignored. unit is a full systemd unit name with its type suffix and must exist on the node (systemctl show -p LoadState); interface is at most 15 characters of letters, digits, -, _ and . and the words all and interfaces are refused. Every argument is validated by System::NodeInspection before a task row exists, by System::Task on any row minted another way, and again by the agent (taskguard), which is authoritative; a spec keeps the server and Go lists identical.
file_stat path policy. An allow-list of trees (/etc, /usr, /boot, /persist/var/lib/powernode, /persist/cache/modules, /var/lib/powernode, /run/powernode), an absolute canonical path (no .., ., doubled slashes, spaces or control characters; at most 4096 bytes), and a deny of secret locations inside it: shadow, pki, private, secrets, credentials, .ssh, wireguard, key/token/password-named files and *.key, *.p12, *.env. An allow-list rather than a deny-list because a deny-list is open by default; the sha256 of a low-entropy secret is an offline oracle for it, so secret locations are refused even though no contents are read. /proc (so /proc/<pid>/environ), /sys, /dev, /root, /home, /tmp and /persist/volumes are outside the allow-list. The path is symlink-resolved on the node and the resolved path is judged by the same rule and must stay under the node root, so a link cannot lead out; the file is then opened with openat2 (RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS), so a component swapped for a symlink after the check is refused by the kernel rather than followed.
Network and FUSE mounts are refused. A path held by a mount whose type is nfs*, cifs, smb*, fuse* (fuse.sshfs, fuseblk, ...), 9p, ceph, glusterfs or sshfs (deepest mount wins, per /proc/self/mountinfo; an unreadable mount table refuses) is not touched: /var/lib/powernode hosts NFS storage mounts, and a read on a dead export blocks in D-state. file_stat also runs under a 20s deadline and returns a refusal when a path does not answer. The agent runs one task at a time, so one wedged path would otherwise hold every later task for the node (deploys included); the abandoned read leaks until the mount answers, and after four such leaks new file_stat calls are refused until they drain.
Bounds and scrubbing. Every command is read through a bounded runner: at most 256 KiB of stdout is ever held, and the command is killed once that is exceeded (the result says truncated). Journal lines are capped by the argument and again on the returned text; each collector's result is capped at 64 KiB (the journal keeps its newest lines). On the node all text passes through scrubSecrets, fed by the credential shapes of System::ShellOutputSanitizer (name=value, *_KEY/*_TOKEN/*_SECRET/*_PASSWORD env names, URL userinfo, Authorization/Bearer/Basic/Token values, --password v style flags and login ... -p v, curl -u user:pass, bare JWTs, provider tokens, PEM private keys); the raw result is stored in the task's completed event, so the agent scrubs before sending. The stored result is redacted again through System::StoredOutputRedactor on this surface.
Who may inspect what. A user principal needs system.infra_tasks.create and may inspect any instance of its account. An instance principal may inspect only itself: instance_id may be omitted, and naming any other instance is refused on the server, before a task exists, keyed on the principal (not the verb) and recorded as a system.mcp_node_inspect_refused fleet event; a restricted principal with no node identity is refused. The verb is declared mutating: true (it inserts a System::Task and returns root diagnostics, so it is kept off the read-only allowlist an exported agent gets) but not destructive, and it goes through the same governance row the REST task-creation gate reads, system.task.probe.node_inspect, which ships auto_approve (runs inline): an operator who tightens it to require_approval gets the pending envelope and block refuses it. The instance must be running or starting; a stopped, stopping, pending, provisioning or rebooting node is refused instead of taking a task it would run at its next boot. An unknown instance answers with a literal Couldn't find System::NodeInstance message, no account id or SQL.
Waiting. wait_seconds defaults to the cap (45s) since the caller wants the answer; on expiry the reply is a success with timed_out: true and the task_id, and system_get_task (wait_seconds, include_events) reads the same task later. 0 returns the task_id at once. A collector whose tool fails on the node is a completed result with ok: false and the reason; an argument the agent refuses (a unit that does not exist) is an error carrying the reason and the task_id.
system_restart_unit (instance_id, unit, reason) restarts ONE systemd unit that a module composed on an instance, through the node's own agent (an agent restart task, unit-scoped), with no root shell. It is approval-gated on system.task.restart, the same category POST /api/v1/system/tasks reads and which ships require_approval: the reply is pending: true with an approval_request_id and nothing is queued until an operator approves; when the approval lands the generic replay executor re-invokes the verb as the original principal, which creates exactly one task. It is not restart_after_update, which creates the same task shape but never meets the gate.
| Check | Rule |
|---|---|
reason |
Required, non-blank, at most 500 characters; recorded on the task and in the audit log (system.instance.restart_unit, with the unit, task id and requester); it reaches the approver through the request's filtered params, never the approval description |
unit |
The full name, powernode-<module-id>-<service>.service, and it must be one of the services of the modules attached to that instance's node (System::UnitRestartService.composed_units, the same union the node API serves the agent). An unknown unit, or one composed on another instance, is refused |
| Namespace | Anything outside powernode-* is refused; the node agent's own unit and its variants (powernode-agent*) are refused with the message that agent restarts stay out-of-band |
| Self-hosting node | On the node named by self_hosting_node_id every unit is refused (INV-1 is node-scoped). With the setting unset the fence cannot tell, so it fails closed for the services the control plane runs on (rails*, postgres*, pg-replica, redis, vault, traefik, restore-dynamic, sidekiq, worker-web, caddy, by the shipped manifests' service names), naming the setting; other units are not affected. There is no relaxation switch; use the out-of-band path |
| Instance | Must be running or starting, and its agent must be reporting (never-reported and went-silent instances are refused) |
Every check runs at request time (before anything is parked) and again on the approved replay, since the unit may have stopped being composed, or the node become the control plane's host, while the request was parked; the replay then completes with the refusal and creates no task. The agent's taskguard validators (powernode- prefix, an installed unit file) remain the second layer. Requires system.instances.control. Declared mutating and destructive (the class of system_reboot_instance and system_stop_instance) and denied outright to every instance principal by Mcp::Principal::DESTRUCTIVE_TOOL_PATTERNS (*system_restart_unit*); the gate context and the verb refuse an instance principal independently of that overlay.
system_apply_unit_dropin (instance_id, unit, name, directives, revert) writes ONE runtime systemd drop-in, /run/systemd/system/<unit>.d/zz-operator-<name>.conf, through the node's own agent (a unit.dropin task), then runs systemctl daemon-reload. /run is tmpfs, so a reboot reverts it. revert: true removes zz-operator-<name>.conf and no other file, then reloads. It never restarts the unit, but resource limits apply at once: systemd re-applies MemoryMax, CPUQuota and TasksMax to the RUNNING unit on daemon-reload, so they take effect immediately (a MemoryMax below the unit's current memory use bites at apply time). The other directives take effect only at the unit's next restart, which is system_restart_unit, governed separately. The reply's note says the same.
It is human-only and approval-gated on its own category, system.instance.unit_dropin (declared require_approval, agent-less at scope global, the same shape as system.instance.out_of_band_exec). The reply is pending: true, requires_human_session: true with an approval_request_id; nothing is written until a person approves in their own session, and no policy row or tool door can approve it. The approved replay re-invokes the verb as that person, so every check runs again and exactly one task is created.
Every directive the verb admits can only limit the unit or narrow a confinement relaxation it already has; none can add code, identity or privilege.
| Check | Rule |
|---|---|
unit |
Exactly system_restart_unit's rules, from the same code (System::UnitRestartService#target_refusal): a composed powernode-* unit on that instance, never the agent's own, every unit refused on the node named by self_hosting_node_id (and the control plane's critical services while that setting is unset), and a running instance whose agent is reporting |
name |
^[a-z0-9-]{1,32}$; it is the only variable part of the file name |
directives |
A list of {key, value}, at most 32, each key at most once, required unless revert is true (and empty on a revert). Allowed keys only: MemoryMax (bytes with an optional K/M/G/T suffix, 1% to 100%, or infinity), CPUQuota (a whole percentage), TasksMax (a count, 1% to 100%, or infinity), LimitNOFILE (N or SOFT:HARD, soft not above hard, or infinity), AmbientCapabilities and CapabilityBoundingSet, ReadWritePaths (below) |
| Capabilities | Space-separated CAP_* names that must be a subset of the unit's own capability set: the set the agent renders into its capabilities.conf, resolved on the server from the module's config.security (ceiling and privileged) and the service row by the agent's own rule, and read back from that file on the agent. The unit's set is REPLACED, so the drop-in can only narrow it. An empty value (zero capabilities) is always accepted. When the set cannot be resolved (a privileged module, which renders no capabilities.conf, or a service declaring capabilities outside its module's ceiling) only the empty value is accepted |
ReadWritePaths |
Rendered with systemd's - in front of every entry, so a path that does not exist is ignored instead of failing the unit's start. Without + the path is the host's, also for a RootDirectory= unit, and the host path is what is checked. Space-separated absolute, clean paths strictly beneath /persist, never at, beneath or above (an ancestor such as /persist/var/lib) the agent's own state and trust material: /persist/var/lib/powernode (state, PKI, module-signing keys, staged compositions), /persist/cache (module and boot-image blobs), /persist/etc (identity, module-signing configuration), /persist/lint-discovery, /persist/powernode-traefik (ingress certificates and routers), /persist/dev. /run/powernode is not allowed at all: everything the agent keeps there is its own. systemd's - and + path prefixes are refused (every path must start with /) |
| Refused | Every other key, including Environment= ("not settable through this verb; env tuning needs manifest-declared tunables": a denylist of dangerous names such as LD_PRELOAD, NODE_OPTIONS, BASH_ENV or PATH is incomplete by construction), Exec*, User, Group, DynamicUser, SupplementaryGroups, NoNewPrivileges, PermissionsStartOnly, EnvironmentFile, LoadCredential and anything else that names a file; in ANY key or value, a control character (newline, CR, NUL, tab), a non-ASCII character, a backslash (a trailing one continues the line), [ or ] (a section header), a double quote, and % (a systemd specifier) outside the percentage grammars |
The file is rendered from the validated pairs under a single [Service] header. The node agent validates every field again independently (agent/internal/runtime/tasks/handlers/unit_dropin.go; the two sides share one table of cases), refuses a unit it did not generate, checks capability lists against the capabilities.conf it rendered for that unit, and writes through an O_NOFOLLOW directory fd with a temp file that is fsynced and renamed into place, so a symlink planted at <unit>.d or at the target is refused and a failed write leaves the previous file untouched. The audit row (system.instance.unit_dropin) is written in the task's transaction and carries the rendered file as a diff; the control plane cannot see a file already on the node, so the diff's "before" side is labelled untracked. POST /api/v1/system/tasks refuses the unit.dropin command outright, and System::Task refuses a unit.dropin row this verb did not mint. Requires system.instances.control. Declared mutating and destructive and denied outright to every instance principal by Mcp::Principal::DESTRUCTIVE_TOOL_PATTERNS (*unit_dropin*); the gate context and the verb refuse an instance principal independently of that overlay.
What the approver sees. The Autonomy approval queue (ApprovalQueuePanel, the expanded row's "Request Data" block) and get_approval_request both show the request's request_data through Ai::SensitiveParams, which carries the call's parameters, every directive key and value included; none of them is secret-shaped now that Environment= is refused. The approval description names the unit, the instance and the drop-in name.
ReadWritePaths on the node. The server checks each path as a string only: it cannot see the node. The agent also walks every entry from / with lstat, one component at a time, and refuses any symlink component (dangling, looping, in the middle or at the leaf), so the path systemd makes writable is exactly the one checked. A path that does not exist yet is allowed (the rendered - lets the unit start without it) only when its nearest existing ancestor is a directory owned by root and not writable by group or others; otherwise whoever can create the missing name later decides what it is. An entry that exists must be a directory (a regular file, possibly a hard link, is refused) whose parent meets the same rule.
Capabilities are checked against capabilities.conf only. The subset rule reads the set the agent rendered into <unit>.d/capabilities.conf. A drop-in a module ships in the same directory that sorts after it and narrows the set further would be undone by an operator list that is still a subset of capabilities.conf, since zz-operator-* sorts last. No shipped module has such a drop-in today.
Caveats. The ReadWritePaths node check is point-in-time: systemd resolves each path again when the unit starts, so a change between the apply and that start is not seen. The ownership rule constrains non-root units only: a sandboxed root unit that already holds a writable grant on an ancestor can still replace the entry after the check. A staged next-boot composition (pending-compose.json on a pivot node) is not consulted: the drop-in lives in /run, so the next boot removes it whether or not that boot composes a staged set, and the verb neither refuses nor warns while one is staged. The daemon-reload the verb runs also loads any unit files a staged composition has already written under /etc, so a later restart of such a unit (a crash restart included) can pick them up before that boot.
system_clear_ssh_host_key (instance_id, reason) clears ONE instance's recorded SSH host key (System::SshHostKeyWriter.clear!), the recovery for a node whose key changed and whose agent never heartbeats again: every platform SSH path is strict against a recorded key and out-of-band exec requires one, so a stale key locks out the break-glass diagnostic for exactly that case. Clear only: there is no pin verb, because host key material does not travel through tool arguments; the node's agent records its own key on its next heartbeat. It is human-only and approval-gated on its own category, system.instance.ssh_host_key_clear (declared require_approval, agent-less at scope global). The reply is pending: true, requires_human_session: true; nothing is cleared until a person approves in their own session, and that person is the actor on the system.ssh_host_key.cleared audit row (previous fingerprints, the reason; never a key). reason is required (1 to 500 characters). A blank reason, an unknown instance or one with no recorded key is refused before parking. The approved reply says what the next connection will do under the current system.ssh.require_host_key setting, and that out-of-band exec stays refused until the node's next heartbeat records a key. Requires system.instances.control. Declared mutating and destructive and denied outright to every instance principal (*system_clear_ssh_host_key*); see docs/design/ssh-host-key-verification.md.
| Action | What it does | Audience |
|---|---|---|
system_provision_ci_worker |
Mint a Worker row (role ci_worker). Returns the worker record only — the plaintext token is not returned on the MCP surface (a tool result is persisted with the conversation and forwarded to the model provider, IMP-27cc7dceb97b). Fetch it once via POST /api/v1/system/ci_workers/<id>/rotate_token |
operator |
system_terminate_ci_worker |
Tear down a CI worker row (Gitea deregistration is operator-managed) | operator |
system_list_ci_workers |
List CI workers + their last seen-online timestamp | operator, agent |
provision_ci_worker (parent platform alias) |
Same as system_provision_ci_worker; legacy name from the parent platform tool registry |
operator |
Permissions: system.ci_workers.{view,provision,terminate}
| Action | What it does | Audience |
|---|---|---|
system_list_volumes |
List System::Volume rows (filterable by storage_class) |
operator, agent |
system_get_volume |
Fetch one volume row with its assignment + provider state | operator, agent |
system_create_volume |
Create a Volume (logical handle; provider materialization is async) | operator, agent |
system_update_volume |
Edit metadata (description, tags) | operator, agent |
system_delete_volume |
Remove a volume (refuses while assigned) | operator, agent |
system_attach_volume |
Attach a Volume to a NodeInstance | operator, agent |
system_detach_volume |
Detach without destroying | operator, agent |
system_test_nfs_export |
Probe an external NFS export's reachability + auth before creating an assignment | operator, agent |
Volume snapshots + restore (project data protection, APO-5 / DR-2):
The platform's only backup used to be its own database; a project's volumes had no snapshot and no restore. Provider support is optional and declined rather than faked — a provider with no snapshot primitive refuses instead of recording a restore point that does not exist. See STORAGE_SUBSYSTEM.md.
| Action | What it does | Audience |
|---|---|---|
system_snapshot_volume |
Snapshot a ProviderVolume through its provider; refuses where unsupported | operator, agent |
system_list_volume_snapshots |
List a volume's snapshots, newest first (reconcile:true checks each against the provider) |
operator, agent |
system_delete_volume_snapshot |
DESTROYS a restore point — approval-gated (system.volume_snapshot_delete); provider delete first, row dropped only on confirmation |
operator |
system_restore_volume_snapshot |
Restore from a completed snapshot; read restored_in_place (false ⇒ a new volume holds the data; swap_into_place:true swaps it onto the source's instance and device) |
operator |
Permissions: system.volumes.snapshot (create), system.volumes.read
(list), system.volumes.delete (delete), system.volumes.manage (restore).
Delete is additionally approval-gated: it parks under
system.volume_snapshot_delete (an operator-scope policy row — require_approval,
written by System::Governance::PolicyReconciler alone since IMP-10e4f6c3bcd2: on a first
boot through the seed orchestrator's reconcile pass, on every later boot through
rails-start.sh, and on demand via rake system:governance:reconcile; tunable in the
Autonomy modal's storage domain) and is
replayed by Ai::Executors::DeferredToolCall as the original principal once
an operator approves — a {pending: true} response means nothing was deleted.
Storage migration (zero-downtime data moves between providers):
| Action | What it does | Audience |
|---|---|---|
system_list_storage_migrations |
List in-flight + recent migrations | operator, agent |
system_get_storage_migration |
Detail one migration with progress + steps | operator, agent |
system_migrate_storage_component |
Initiate a migration (operator-supplied source/destination + cutover plan) | operator |
system_approve_storage_migration |
Approve a proposed migration (when the chain composer surfaces one for review) | operator |
system_cancel_storage_migration |
Halt an in-flight migration safely | operator |
system_report_storage_migration_progress |
Worker-side progress reporting (worker API; not operator-facing) | worker |
system_get_storage_recommendations |
Read the architect's storage-class recommendations for a workload type | operator, agent |
system_update_storage_recommendations |
Tune the recommendation matrix | operator |
Permissions: system.volumes.{view,create,update,delete,attach,detach}, system.storage_migrations.{view,initiate,approve,cancel}, system.storage_recommendations.{view,update}
| Action | What it does | Audience |
|---|---|---|
system_list_providers |
List configured providers (AWS, GCP, Azure, LocalQemu, …) | operator, agent |
system_get_provider |
Fetch one provider with its region/instance-type catalog | operator, agent |
system_update_provider |
Edit provider config (credentials reference, region allowlist) | operator |
Permissions: system.providers.{view,update}
Backed by Ai::Tools::SystemIngressTool (wraps the ExposeServicePublicly / AcmeCertificateProvision / ReverseProxyCompose skill executors). See INGRESS_TLS_GUIDE.md for the operator-facing workflow.
| Action | What it does | Audience |
|---|---|---|
system_expose_service_publicly |
Expose a service end-to-end: create/reuse an SDWAN VIP, port-map it on the hub, provision a TLS cert for the hostname, regenerate the reverse proxy | operator, agent |
system_set_service_backends |
Declare a published service's backend set — the list IS the set (upsert by address, remove what is absent, [] clears back to the legacy single backend) with an explicit backend_port per member, plus per-service load-balancer overrides. Approval-gated under system.service_backends_update (require_approval by default) — pending: true means the set is NOT yet written. system_get_service reports out_of_rotation: true when every declared member is draining (no router is rendered for it) |
operator, agent |
system_acme_provision_certificate |
Issue an ACME TLS certificate for a hostname (dns-01 / http-01 / tls-alpn-01); stored as a System::AcmeCertificate |
operator, agent |
system_acme_create_dns_credential |
Create + verify a DNS-01 provider credential (cloudflare, route53, gcloud, digitalocean, hetzner, porkbun, ovh) | operator, agent |
system_acme_get_certificate |
Fetch a single System::AcmeCertificate (status / expiry / SANs) |
operator, agent |
system_acme_renew_certificate |
Re-run issuance for an existing certificate | operator, agent |
system_acme_revoke_certificate |
Revoke a certificate (irreversible; reason e.g. key_compromise) |
operator, agent |
system_reverse_proxy_compose |
Regenerate the Traefik dynamic config so the platform serves an already-valid issued certificate | operator, agent |
Permissions: system.ingress.manage, system.acme.manage
system_acme_provision_certificatedoes create + issue in one call; the finer-grainedget/renew/revokewrappers andcreate_dns_credentialare backed byAi::Tools::SystemAcmeTool. Seerunbooks/acme-issuance.mdfor the full lifecycle.
Backed by Ai::Tools::SystemStorageOwnerTool. Manages StorageAssignment ownership (service_user / operator / nobody / root) and tracks the on-node chown that applies it.
| Action | What it does | Audience |
|---|---|---|
system_assign_storage_owner |
Assign a StorageAssignment's owner (kind + service user); dispatches the on-node chown | operator |
system_list_storage_assignments_by_owner |
Audit query — filter assignments by owner_kind, service_user_username, chown_state, or node_instance_id (e.g. "which assignments still use owner_kind=root?") |
operator, agent |
system_storage_chown_status |
Inspect chown state of one assignment — current state, previous_uid/gid, task id, timestamps, recorded error | operator, agent |
system_storage_chown_retry |
Re-dispatch a failed / manual_required chown (resets chown_last_error → pending); force_complete: true escape hatch for operator-handled external mounts |
operator |
Permissions: system.storage_owner.{assign,view} (chown status/retry covered by the owner-management grant).
Backed by Ai::Tools::SystemFleetTool. Locate GPU-bearing capacity and stand up an inference runtime.
| Action | What it does | Audience |
|---|---|---|
system_find_node_with_gpu |
Find live NodeInstances exposing a GPU/accelerator; filter by gpu_type, min_gpu_memory_mb, minimum GPU count |
operator, agent |
system_list_instance_types_by_gpu |
List provider instance-type SKUs that carry a GPU (filter by gpu_type + min count) — pick a SKU before provisioning |
operator, agent |
system_deploy_inference_server |
Deploy an inference runtime (ollama) onto a GPU node: assigns gpu-nvidia-runtime + inference-ollama modules and registers an Ai::Provider so the endpoint is consumable |
operator, agent |
Permissions: system.instances.view (discovery) + system.instances.provision (deploy).
Backed by Ai::Tools::SystemFleetTool. The L0–L3 substrate for running agents on fleet instances: isolation-tier selection (L0), per-instance platform-MCP grants (L2), agent-to-agent capability tokens + grants (L2.5/A2A), and full agent-fleet orchestration missions (L3). All capability surfaces are default-deny.
| Action | What it does | Audience |
|---|---|---|
system_list_isolation_tiers |
L0 — list isolation tiers an agent deployment can request (native / gvisor / kata / firecracker / vm) with their Docker runtime + K8s RuntimeClass mapping |
operator, agent |
system_grant_instance_mcp_tools |
L2 — grant an instance-agent the platform-MCP tool-name glob patterns it may invoke (default-deny until granted) | operator |
system_discover_peers |
A2A — list online, operator-enabled agent peers in the account and the skills they offer (capability discovery) | operator, agent |
system_grant_instance_peer_skills |
A2A — grant an instance-agent the peer skill-name glob patterns it may invoke on other instances (default-deny) | operator |
system_mint_peer_capability_token |
Always refuses (IMP-27cc7dceb97b) — the envelope+signature pair is signing material and an MCP result is persisted with the conversation and forwarded to the model provider. Use system_authorize_peer_call to check the policy; system_launch_agent_fleet for a cross-instance call (token minted server-side into the delegation descriptor); POST /api/v1/system/node_instance_peers/<id>/execute for a self-edge call (needs system.peers.execute) |
operator, agent |
system_authorize_peer_call |
A2A — three-gate, default-deny authorization decision for a peer skill invocation (caller granted + target online/enabled + skill offered) | agent |
system_launch_agent_fleet |
L3 — launch an agent-fleet orchestration mission: provision a fleet of agent-instances, grant L2 + L2.5 capabilities, delegate work | operator |
system_agent_fleet_status |
L3 — inspect an agent-fleet mission: status, current_phase, member/assignment counts, aggregated report, reaped count | operator, agent |
Permissions: system.a2a.{discover,grant,authorize}, system.agent_fleet.{launch,view}, system.instances.grant_mcp_tools.
These two actions are system_*-prefixed (not system_sdwan_*) but route through Ai::Tools::SdwanTool — they compose multiple SDWAN primitives into one operator-facing call.
| Action | What it does | Audience |
|---|---|---|
system_multi_tenant_isolation |
Provision a fully-isolated SDWAN network slice for a single tenant — dedicated overlay with its own VRF + isolated iBGP RIB, non-overlapping /64, default-deny nftables baseline | operator |
system_service_discovery_compose |
Make a backend service discoverable across the fleet over the overlay — provisions a VIP (auto-advertised via iBGP) + a VIP-backed front end | operator, agent |
Permissions: system.sdwan.networks.manage (composition wraps the underlying network/VIP grants).
| Action | What it does | Audience |
|---|---|---|
system_deploy_platform |
Deploy a standalone (sovereign) Powernode platform, or return the deployment wizard payload. Federated spawns are refused on this surface — see below | operator |
system_platform_maintenance |
Concierge-bound skill — surface fleet-wide maintenance posture + scheduled windows | operator, agent |
system_platform_resilience |
Concierge-bound skill — fleet-wide resilience posture + incident recovery suggestions | operator, agent |
Permissions: system.platform.deploy (deploy), system.platform.read (maintenance), system.platform.scale (resilience) — per SystemFleetTool::ACTION_PERMISSIONS. The latter two are bound to System Concierge. (Note: the auto-generated catalog at docs/reference/auto/mcp-tools.md prints the tool class's REQUIRED_PERMISSION floor, system.nodes.read, for every action — the per-action entries above are what actually gates these calls.)
See docs/federation/SPAWN_MODES.md for the spawn-mode comparison.
Federated spawns do not go over MCP. A federated deploy mints a single-use
acceptance token, and an MCP tool result is forwarded to the model provider and
persisted with the conversation — so system_deploy_platform refuses
mode: "federated" and names the operator path in the error
(IMP-c0687cfb3a05). Operators deploy federated platforms over REST:
POST /api/v1/system/platform/deployments(permissionsystem.platform.deploy) — runs the sameSystem::PlatformDeploymentOrchestratorand revealsacceptance_tokenexactly once in its HTTP response. This is what the Deploy Platform wizard submits to.POST /api/v1/system/federation/children/spawn— the lower-level spawn endpoint, likewise unaffected.
system_deploy_platform remains the MCP path for standalone deployments and for
fetching the wizard payload.
| Action | What it does | Audience |
|---|---|---|
system_destroy_instance |
Tear down a NodeInstance immediately (skips drain — operator must accept blast radius) | operator |
system_drain_instance |
Cordons and STOPS the instance — flips a ready pool member to pool_state=draining (which the allocator reads), then stops the VM through System::InstanceControlService, the same choke point system_stop_instance uses. Requires system.instances.control; refuses an instance on this plane's own hosting node; a refused or failed stop comes back as an error, not as a drain. Nothing relocates in-flight work first — do that BEFORE calling it. The drain_* config markers and timeout_seconds are gone (IMP-f4fe1ed1ec1e) — see node-provisioning.md Phase 5 |
operator |
system_cordon_instance |
Cordon-only (unschedulable) mode — the reversible half of the drain. Marks the instance unschedulable WITHOUT stopping it: a ready pool member is fenced out of the allocator (pool_state=draining, the one state InstancePoolService#acquire! never picks and the reaper never touches — so pool acquires, CI runner leases and agent-fleet missions stop landing on it), a claimed member is marked only (and fenced rather than re-admitted when its consumer returns it to a reuse_without_reset pool), a non-pool instance is marked only, and on a deployment replica the replica reconciler honours the marker — out of the live count (a replacement is provisioned), first scale-in victim. Records who/why/when under the instance's cordon config key (visible on system_get_instance). Requires system.instances.control; approval-gated under system.instance_cordon (require_approval by default) — a pending: true envelope means it is NOT yet cordoned. See node-provisioning.md Phase 5 |
operator |
system_uncordon_instance |
Lifts a cordon: clears the marker and hands a member this cordon fenced back to the allocator as ready (with a fresh ready-TTL anchor) — only while it is still draining AND running; refuses to re-admit a stopped member (start it first) and never writes ready over a member that was recycled or errored meanwhile. Gated under the SAME system.instance_cordon category — one operator row governs both directions |
operator |
system_get_silent_instances |
Audit query — list NodeInstances whose last_heartbeat_at exceeds the silent threshold |
operator, agent |
system_refresh_instance_modules |
Force the agent to re-reconcile its module set against the platform's view | operator, agent |
system_module_mark_canary |
Mark a module as a honeypot canary (any access emits a high-severity FleetEvent) | operator |
system_recycle_pool |
Drain + replace all instances in an InstancePool (slice 7 — pool refresh) |
operator |
system_delete_template |
Remove a NodeTemplate (refuses while in use) | operator |
system_unassign_module_from_template |
Drop a module → template binding and, in the same transaction, purge the NodeModuleAssignments it produced (a row another join still needs is re-pointed; hand-authored rows untouched). Returns purged_assignments {count, node_ids} |
operator |
system_delete_module |
Remove a NodeModule (refuses while assigned) | operator |
system_delete_node |
Remove a Node row (refuses if any NodeInstance still exists; cascade delete is system_terminate_instance first) |
operator |
Permissions: the action verb on the matching resource (system.{node_instances,node_modules,instance_pools,…}.{view,destroy,delete,recycle,refresh,mark_canary,drain}).
Backed by Ai::Tools::SystemFleetTool. Sensor thresholds are per-account
configuration resolved from System::Fleet::SensorConfig with each sensor's
class constant as the fallback — see
FLEET_SENSORS.md for the
tunable keys and their defaults.
| Action | What it does | Audience |
|---|---|---|
system_get_sensor_config |
Read a sensor's defaults, stored overrides and effective thresholds (omit sensor to list every configurable sensor) |
operator, agent |
system_update_sensor_config |
Merge threshold overrides for one sensor; a null value drops an override. Rejects undeclared keys and non-positive values |
operator |
Permissions: system.fleet.read (read), system.fleet.manage (write).
| Action | What it does | Audience |
|---|---|---|
system_get_cve |
Fetch a single CVE row (CVE-id, severity, affected packages, references) | operator, agent |
system_create_cve |
Create a CVE row manually (typically driven by the NVD feed worker; operator override path) | operator |
system_delete_cve |
Remove a CVE row (audit-cleanup) | operator |
system_get_cve_exposure |
Per-NodeInstance exposure detail — which assigned modules ship the affected packages | operator, agent |
Permissions: system.cves.{view,create,delete}, system.cve_exposures.view. CVE remediation runs through the CVE Responder agent's intervention policies (see FLEET_SENSORS.md §"CVE Responder agent (5 policies)") rather than direct MCP actions.
| Action | What it does | Audience |
|---|---|---|
system_rotate_vault_transit_pepper |
Rotate the Vault transit pepper used for per-account encryption-key wrapping (DR scenario; rare) | operator |
Permissions: system.vault.rotate_transit_pepper. See runbooks/vault-credential-restoration.md for the broader rotation workflow.
Backed by Ai::Tools::SdwanTool. Comprehensive network management.
| Action | What it does |
|---|---|
system_sdwan_list_networks |
List Networks in account |
system_sdwan_get_network |
Fetch a Network + its topology summary |
system_sdwan_create_network |
Create a Network with prefix + routing_mode |
system_sdwan_update_network |
Edit metadata (description, routing_mode toggle) |
system_sdwan_delete_network |
Destroy a Network and all its peers/VIPs/rules |
| Action | What it does |
|---|---|
system_sdwan_list_peers |
List Peers on a Network |
system_sdwan_get_peer |
Fetch a Peer + its current handshake state |
system_sdwan_attach_peer |
Add a NodeInstance as a Peer; allocates /128 |
system_sdwan_detach_peer |
Remove a Peer from a Network |
system_sdwan_rotate_peer_key |
Rotate a Peer's WireGuard key in place (same peer id and overlay address); approval-gated under sdwan.peer_key_rotate, returns public-key fingerprints only, denied to instance principals |
system_sdwan_get_topology |
Network-wide reachability + handshake summary |
| Action | What it does |
|---|---|
system_sdwan_list_firewall_rules |
List rules on a Network |
system_sdwan_get_firewall_rule |
Fetch a rule by id |
system_sdwan_create_firewall_rule |
Create a rule (selector + action + protocol + port_range) |
system_sdwan_update_firewall_rule |
Edit a rule |
system_sdwan_delete_firewall_rule |
Remove a rule |
| Action | What it does |
|---|---|
system_sdwan_list_access_grants |
List active access grants |
system_sdwan_create_access_grant |
Issue a single-use bootstrap URL for a user device (15-min default expiry) |
system_sdwan_revoke_access_grant |
Revoke a grant — cascades to revoke EVERY device on it. Approval-gated (sdwan.access_grant_revoke) — returns pending: true + a deferred_operation_id until approved |
system_sdwan_list_user_devices |
List active UserDevices on a Network |
system_sdwan_issue_user_device |
Convert an access grant + user-side public key into a UserDevice |
system_sdwan_revoke_user_device |
Revoke ONE UserDevice (cuts off that device's VPN access; siblings and the grant are untouched). Approval-gated (system.sdwan_user_device_revoke) — returns pending: true + a deferred_operation_id until approved |
| Action | What it does |
|---|---|
system_sdwan_list_federation_peers |
List federation peers on a Network |
system_sdwan_get_federation_peer |
Fetch a federation peer |
system_sdwan_propose_federation_peer |
Account A proposes peering with Account B (out-of-band — does NOT spawn a child platform; use the children-spawn REST endpoint for that). Approval-gated (sdwan.federation_peer_propose) — returns pending: true + a deferred_operation_id and no peer row exists until approved. Acceptance-token minting is refused on this surface (IMP-3a32dc649043); propose over POST /api/v1/system/sdwan/federation_peers when you need the token |
system_sdwan_accept_federation_peer |
Account B accepts a proposed peering (moves the row to status: "accepted"). Approval-gated (sdwan.federation_peer_accept) — returns pending: true + a deferred_operation_id until approved |
system_sdwan_update_federation_peer |
Update a federation peer (e.g. its priority-ordered endpoints list). The two trust-boundary transitions are approval-gated: status: "accepted" on sdwan.federation_peer_accept, status: "revoked" on sdwan.federation_peer_revoke; other transitions apply inline |
system_sdwan_revoke_federation_peer |
Cancel a federation relationship from either side — cuts cross-instance routing. Approval-gated (sdwan.federation_peer_revoke) — returns pending: true + a deferred_operation_id and nothing is cut until approved. Same category and executor as system_sdwan_update_federation_peer with status: "revoked", so one policy covers every route to a revoked peer |
system_sdwan_federation_scan |
Scan for proposed-but-not-accepted peers (for operator review) |
system_sdwan_federation_compose |
Stand up a federation overlay topology (hub-and-spoke OR full-mesh) across instances — creates one Sdwan::Network, enrolls each member as a peer (hubs publicly_reachable), and compiles the routing plan |
system_sdwan_set_data_residency |
Stamp a peer with a scalar data_residency region/tag (P9.4 enforcement). Approval-gated (sdwan.federation_peer_data_residency, seeded require_approval) and audited on the peer's trail as federation.peer.data_residency_changed; the operator twin is PATCH /sdwan/federation_peers/:id with data_residency |
system_sdwan_get_audit_log |
Read a peer's per-peer WORM audit log (P9.3) |
Permissions: system.sdwan.federation_peers.{view,propose,accept,revoke}
| Action | What it does |
|---|---|
system_sdwan_update_peer_lan_subnets |
Declare LAN subnets a peer advertises over iBGP |
system_sdwan_update_network_routing_mode |
Toggle network between static and ibgp |
system_sdwan_list_subnet_advertisements |
List active subnet advertisements |
system_sdwan_get_routing_summary |
Network-wide routing table summary |
| Action | What it does |
|---|---|
system_sdwan_create_virtual_ip |
Allocate a VIP with primary + failover holders |
system_sdwan_list_virtual_ips |
List VIPs on a Network |
system_sdwan_get_virtual_ip |
Fetch a VIP + its assignments |
system_sdwan_update_virtual_ip |
Edit VIP metadata (failover candidates, name) |
system_sdwan_delete_virtual_ip |
Free a VIP back to the network |
system_sdwan_failover_virtual_ip |
Promote next failover candidate (manual or scripted via skill) |
system_sdwan_list_vip_assignments |
List assignment history (audit trail) |
| Action | What it does |
|---|---|
system_sdwan_get_account_bgp |
Fetch per-account ASN + BGP global config |
system_sdwan_update_account_as_number |
Set the account's ASN (private 64512–65534) |
system_sdwan_get_bgp_sessions |
List iBGP sessions on a Network with their states |
system_sdwan_get_bgp_config_for_peer |
Fetch FRR config snippet for a peer (debugging) |
| Action | What it does |
|---|---|
system_sdwan_list_route_policies |
List policies on a Network |
system_sdwan_get_route_policy |
Fetch a policy by id |
system_sdwan_create_route_policy |
Create a JSONB-statement policy |
system_sdwan_update_route_policy |
Edit a policy |
system_sdwan_delete_route_policy |
Remove a policy |
system_sdwan_compile_route_policy |
Render the FRR route-map + aux objects (audit) |
| Action | What it does |
|---|---|
system_sdwan_list_port_mappings |
List port mappings on a Network |
system_sdwan_get_port_mapping |
Fetch a mapping |
system_sdwan_create_port_mapping |
Map an external :port → internal [<peer-/128>]:port |
system_sdwan_update_port_mapping |
Edit a mapping |
system_sdwan_delete_port_mapping |
Remove a mapping |
| Action | What it does |
|---|---|
system_sdwan_list_host_bridges |
List Sdwan::HostBridge rows (per-host bridge allocations) |
system_sdwan_get_host_bridge |
Fetch one bridge by id, with its lifecycle timestamps |
system_sdwan_create_host_bridge |
Allocate a new host bridge (operator or Topology Designer) |
system_sdwan_activate_host_bridge |
Mark a bridge as active so the agent picks it up on next reconcile |
system_sdwan_release_host_bridge |
Release a host bridge — drains by default, force: true removes it immediately |
Permissions: system.sdwan.host_bridges.read (list/get) and
system.sdwan.host_bridges.manage (create/activate/release).
Releasing drains by default. Without force, the bridge moves to
draining: it stays in the compiler's emit set and keeps its short_id
reserved so in-flight taps finish before the id can be reissued.
Sdwan::HostBridgeReaper (daily, System::HostBridgeReaperJob) then sweeps
draining rows past a 24h grace window to removed, which is what makes the
default safe — before IMP-53a5c597ec8c there was no such sweep, the state
machine had no edge out of draining, and a non-forced release left the
bridge serving on the host indefinitely. force: true skips the window and
marks the bridge removed at once — the compiler stops emitting it
immediately and anything mid-provision against it loses its bridge name.
Two states short-circuit the drain, on every surface, because the drain edge
is wrong for them: a pending bridge was never applied to the host, so
draining it would instead make it compilable and cause the agent to create
the bridge you just released — it goes straight to removed. An already
removed bridge is left untouched rather than silently no-opping. The REST route (DELETE /api/v1/system/sdwan/host_bridges/:id) carries the identical default and the
identical force opt-in; before IMP-53a5c597ec8c it hard-forced
unconditionally, so an operator delete skipped a window an agent release
honored.
All three write verbs are approval-gated (sdwan.host_bridge_create /
_update / _delete) on both the MCP and REST surfaces, so the tier an
operator configures actually binds. Under require_approval the call returns
pending: true with a deferred_operation_id and nothing is written until an
operator approves. An approver reading a host_bridge_delete card should check
the operation's force param — the same category covers both the drain and
the immediate teardown.
REST parity: GET/POST /api/v1/system/sdwan/host_bridges, GET/DELETE
…/:id, and POST …/:id/activate mirror these five actions one-for-one.
| Action | What it does |
|---|---|
system_sdwan_create_ovn_deployment |
Create an OVN deployment on a network (manager + controller + switches) |
system_sdwan_delete_ovn_deployment |
Tear down the OVN deployment + its switches/ACLs |
system_sdwan_create_ovn_logical_switch |
Create a logical switch in the deployment |
system_sdwan_delete_ovn_logical_switch |
Remove a logical switch (refuses while ports are attached) |
system_sdwan_create_ovn_logical_switch_port |
Attach a port to a logical switch (typed: localnet / VIF / patch) |
system_sdwan_create_ovn_acl |
Create an OVN ACL (direction + match + action — fine-grained pod policy) |
system_sdwan_delete_ovn_acl |
Remove an ACL |
system_sdwan_list_ovn_acls |
List ACLs on a logical switch |
system_sdwan_compile_ovn_plan |
Render the full OVN plan (debug + audit before apply) |
Permissions: system.sdwan.ovn.{view,deploy,destroy,configure} — composed via Topology Designer agent's sdwan_ovn_compose_topology skill in typical use.
| Action | What it does |
|---|---|
system_sdwan_list_ipfix_collectors |
List the account's IPFIX collectors |
system_sdwan_get_ipfix_collector |
Fetch one collector, including is_winning_collector (only the oldest active row is stamped onto the OVS bridges) |
system_sdwan_create_ipfix_collector |
Register a collector endpoint (host + port) for flow export |
system_sdwan_update_ipfix_collector |
Enable/disable a collector — use this, not delete, to stop flow export |
system_sdwan_delete_ipfix_collector |
Remove a collector and every flow sample recorded against it (dependent: :destroy + cascade FK) |
Permissions: system.sdwan.ipfix.read (list/get) and system.sdwan.ipfix.manage (create/update/delete) — see Ai::Tools::SdwanTool::ACTION_PERMISSIONS, which is the authority. (The system.sdwan.ipfix_collectors.{view,create,delete} names this table carried previously were never implemented.) Composition typically runs through the sdwan_ipfix_collector_compose skill (Topology Designer).
Delete is not a stand-in for disable: it cascades the collector's flow_samples, taking with it the correlation history System::Fleet::Sensors::SdwanServiceHealthSensor reads. system_sdwan_update_ipfix_collector stops the export and keeps both (IMP-6bbe5c673c38).
Backed by Ai::Tools::KubernetesClusterTool + KubernetesProvisioningTool. Phase 2 ships read + decommission + kubeconfig retrieval; cluster creation is implicit (assigning k3s-server module to a NodeInstance creates a cluster).
| Action | What it does |
|---|---|
kubernetes_list_clusters |
List clusters in the account |
kubernetes_get_cluster |
Fetch cluster details (status, flavor, api_endpoint, node count) |
kubernetes_list_nodes |
List KubernetesNodes for a cluster (control-plane + workers) |
kubernetes_decommission_cluster |
Cascade-delete cluster + its KubernetesNodes (NodeInstances NOT terminated) |
kubernetes_get_kubeconfig |
Retrieve kubeconfig YAML + api_endpoint VIP |
Backed by 7 tool classes: DockerContainerTool, DockerServiceTool, DockerStackTool, DockerClusterTool, DockerHostTool, DockerImageTool, DockerNetworkVolumeTool. Works on both managed (Powernode-provisioned) and external (operator-registered) hosts. Per memory powernode.docker_mcp_tools.
| Action | What it does |
|---|---|
docker_list_containers |
List containers on a host |
docker_get_container |
Detailed info on one container |
docker_create_container |
Create from an image |
docker_start_container / docker_stop_container / docker_restart_container |
Lifecycle |
docker_delete_container |
Remove (force flag available) |
docker_container_logs |
Retrieve logs (tail + since filters) |
docker_container_stats |
Live CPU/mem/network I/O stats |
docker_container_exec |
Execute a command in a running container |
| Action | What it does |
|---|---|
docker_list_services / docker_get_service / docker_create_service / docker_update_service |
Lifecycle |
docker_scale_service |
Scale to N replicas |
docker_rollback_service |
Rollback to previous version |
docker_delete_service |
Remove a service |
docker_service_logs |
Aggregated logs across all tasks |
docker_service_tasks |
List tasks with status + node placement |
| Action | What it does |
|---|---|
docker_list_stacks / docker_get_stack |
Inspection |
docker_deploy_stack |
Deploy/redeploy from Compose YAML |
docker_delete_stack |
Remove all services in a stack |
docker_adopt_stack |
Adopt an externally-deployed stack into Powernode |
| Action | What it does |
|---|---|
docker_list_clusters / docker_get_cluster / docker_cluster_health |
Inspection |
docker_list_nodes |
List nodes with role + availability |
docker_node_promote / docker_node_demote |
Worker ↔ manager |
docker_node_drain / docker_node_activate |
Drain/resume scheduling |
docker_list_secrets / docker_create_secret / docker_delete_secret |
Swarm secrets |
docker_list_configs / docker_create_config / docker_delete_config |
Swarm configs |
| Action | What it does |
|---|---|
docker_list_hosts |
List all Docker hosts (managed + external) |
docker_get_host |
Detailed host info (OS, resources, Docker version) |
docker_sync_host |
Refresh containers/images from daemon |
docker_test_host |
Test connection to a host |
| Action | What it does |
|---|---|
docker_list_images |
List images with tags + size |
docker_pull_image |
Pull from registry |
docker_delete_image |
Remove (force flag available) |
docker_tag_image |
Tag with new repo + tag |
| Action | What it does |
|---|---|
docker_list_networks / docker_create_network / docker_delete_network |
Network lifecycle |
docker_list_volumes / docker_create_volume / docker_delete_volume |
Volume lifecycle |
.verify/ASPIRATIONAL_MCP.md is the catalog of documented actions that are not registered — verbs a runbook or tutorial names on purpose, with the REST/console workaround at the call site. The count is deliberately not restated here: that catalog is derived from ASPIRATIONAL_VERBS in server/spec/docs/module_docs_mcp_call_signatures_spec.rb and pinned to it by an equality oracle, and a hand-kept second copy of the number is exactly what went wrong before (this paragraph asserted the backlog was empty from 2026-06-04 until 2026-09-01, while two entries sat in FLEET_SENSORS.md). Read the catalog for the current set.
Three wrappers did leave the backlog on 2026-06-04 — system_get_task, system_revert_disk_image and system_update_module_assignment are implemented and registered in platform_api_tool_registry.rb. To add a new aspirational reference, comment the example out, say so in the prose, then add the entry to ASPIRATIONAL_VERBS and the row to the catalog; remove both when the wrapper ships.
Operator runbooks under docs/runbooks/ should reference current registry actions only; cross-validate against this doc + the aspirational catalog before adding a new runbook step.
- Pick the right tool class in
server/app/services/ai/tools/(parent) — extend if it fits a domain; create new if introducing a new resource family. - Implement the action method on the tool class. Return a
{ success: bool, data: hash, error: string }shape. - Add an entry to
platform_api_tool_registry.rb—"my_new_action" => "Ai::Tools::MyTool". - Add
action_definitionsfor the new action describingdescription,parameters,permission. The MCP server uses these to advertise the tool to clients. - Add a permission row in a migration (
db/migrate/<timestamp>_add_<name>_permission.rb). - Run
bundle exec rails mcp:generate_tool_catalog— regeneratesdocs/platform/MCP_TOOL_CATALOG.md(gitignored). - Update this
MCP_API_REFERENCE.mdwith the new action — the manually-curated subset for operators.
| Prefix | Unique actions | Tool classes |
|---|---|---|
system_* (excl. sdwan) |
128 | SystemFleetTool + SystemArchitectureCatalogTool + SystemPackageRepositoryTool + DockerProvisioningTool + DiskImageOperatorTool + SystemIngressTool + SystemAcmeTool + SystemStorageOwnerTool + topology / storage tools |
system_sdwan_* |
73 | SdwanTool (incl. OVN + IPFIX + host-bridge + federation accept/compose subsections) |
kubernetes_* |
5 | KubernetesClusterTool + KubernetesProvisioningTool |
docker_* |
52 | 7 tool classes (Container, Service, Stack, Cluster, Host, Image, NetworkVolume) |
| Total | code-derived from platform_api_tool_registry.rb — regenerate the machine catalog (rails mcp:generate_tool_catalog) for the authoritative count. For actions documented but NOT registered, read .verify/ASPIRATIONAL_MCP.md rather than a count restated here |
Counts are code-derived from
platform_api_tool_registry.rbon 2026-06-03. The parent platform's full machine-generated catalog (with parameter schemas) is the authoritative cross-check — regenerate it withcd server && bundle exec rails mcp:generate_tool_catalog.
SKILL_EXECUTORS.md— skill executors (compose multiple MCP actions into orchestrations);SKILL_EXECUTOR_CATALOG.mdis the auto-generated full listFLEET_SENSORS.md— fleet sensors (signals → autonomy actions → MCP action invocation)runbooks/— operator runbooks (use these MCP actions in concrete workflows)server/app/services/ai/tools/platform_api_tool_registry.rb(parent platform) — source of truth for action-to-tool mapping
Last verified: 2026-06-03 (rev 2)