| name | script-authoring |
| description | Use when drafting or revising the custom-platform JSON itself. Four pattern recipes (ssh-interactive, ssh-batch, http-api, http-form-fill) cite schema, samples, and templates and cover Do blocks, status messages, custom parameters, and reserved variables. The http-api recipe spans every auth shape the API documents — Basic/Digest via HttpAuth, or Bearer / custom Authorization scheme / custom-header API key via script-built Headers — plus one-step vs two-step token fetch. Mandates the fast inner loop: local schema validation against schema/ before any appliance round-trip. SchemaOnly green is necessary but not sufficient — cross-reference samples for analogous patterns before declaring ready. |
script-authoring
Pre-flight
Before drafting or revising any platform JSON, consult AGENTS.md for the active workflow algorithm (new-platform vs enhance-platform) and the iterative debug-loop budget. If the operator skipped target probing or strategy selection and went straight to "write me the JSON", surface that — the wrong pattern compiles cleanly but fails on the appliance.
Scope
Four pattern sub-recipes cover the supported transports:
Telnet/TN3270 is out of scope for the agent skill system. The recipes below are starting points; pick one based on strategy-selection output and adapt it.
Modes
author-only, probe-only, full-loop. The skill never directly contacts the appliance — it produces JSON and hands off to safeguard-ps-operations.
Authoritative inputs
Mandatory: fast inner loop first
Local JSON Schema validation runs before any appliance round-trip:
./tools/Invoke-PlatformDevLoop.ps1 -ScriptFile <draft.json> -SchemaOnly
Sub-second, no appliance contact, exit 0 on pass and 1 on schema rejection. Only after this passes does the agent move to -ValidateOnly (server dry-run) and then to import + trigger.
SchemaOnly is necessary, not sufficient
A green local schema check proves the JSON parses and conforms to the schema. It does not catch:
- Undefined variables referenced inside
Do blocks (%FuncUserName% vs %FuncUsername%, etc. — the schema does not parse %…% substitutions).
- Regex in
ExpectRegex / Condition.If that compiles but does not match real target output.
Send / Receive ordering that drifts out of sync with the actual prompt.
- Status messages emitted in the wrong order or at the wrong phase.
Before declaring a draft "ready to import," cross-reference an analogous sample from samples-index.md. If a sample uses a construct your draft does not (e.g., a Try/Catch around Disconnect, a Receive flush of the login banner, a Headers block before HttpAuth), surface that divergence to the operator rather than silently omitting it.
Conventions all four patterns share
- Top-level shape.
Id, BackEnd: "Scriptable", optional Meta, optional Imports, then one object per operation (CheckSystem, CheckPassword, ChangePassword, …). Operation objects contain Parameters (array of single-key objects) and Do (array of command objects). See schema/custom-platform-script.schema.json lines 14–80 for the top-level fields, and docs/reference/script-structure.md for prose.
- Reserved parameters are not declared by the script — SPP injects them. Custom parameters are declared in
Parameters and addressed as %Name%. See docs/reference/reserved-parameters.md and docs/reference/custom-parameters.md.
- Before declaring a custom parameter, grep
docs/reference/reserved-parameters.md for the concept. Reserved names like SkipServerCertValidation, UseSsl, CheckHostKey, HostKey, HttpProxyUri/Port/UserName/Password, and TacacsSecret are auto-sourced from the asset's settings — declare them in the operation's Parameters array exactly like a custom param and SPP populates them at runtime with zero -CustomScriptParameters plumbing at onboarding. Inventing a custom name for any of these concepts (e.g. declaring your own IgnoreCert Boolean) works in isolation but forces every onboarding operator to remember the override forever. Always prefer the reserved name.
- Secrets. Any parameter that holds a credential MUST be
Type: "Secret" so SPP redacts it in task logs (see the redaction note in safeguard-ps-operations and tools/README.md, "Secret handling"). Use the ::$ modifier (%FuncPassword::$%) where the templates and samples do; do not invent a different escape.
Try / Catch. Wrap fallible operations (network calls, command execution, parses) so a clean Disconnect still runs and a structured Return/Throw is produced. Both templates/TemplateSshMinimal.json and templates/TemplateHttpMinimal.json demonstrate this shape end-to-end.
- Return values. End each operation with
Return (typically %CheckResult% or a discovery payload). Never let an operation fall off the end of Do without a return.
- Status messages. Emit them via the supported logging commands (see
docs/reference/status-messages.md) — they end up in the task log and are how task-log-analysis knows how far the script got.
If a Do-block construct does not appear in any sample or template, stop and ask before adding it. The grounding rule applies inside the JSON, not just around it.
Bake diagnostics in on the first try
Every appliance round-trip costs operator time. Before triggering, walk every failure branch and ask: will the task log tell me why, or will I need another iteration? If the answer is "another iteration", instrument first. Concrete rules for parsed Send / ExecuteCommand blocks (capture stderr, capture exit codes, suppress sudo prompt, terminate Send with \n, echo parsed buffer back) live in docs/agent-reference/script-authoring-deep-dives.md.
When two iterations fail with the same signature, stop drafting and grep
Same classified phase + same signature on N and N+1 means the hypothesis is wrong. Switch from drafting to sample-mining: grep samples/<protocol>/ for the failing construct, read the matching operation in full context, port the working shape as a single change. Detail in docs/agent-reference/script-authoring-deep-dives.md.
Function-call signatures: copy from samples, do not infer
Function calls use a positional Parameters array; arity and order matter. Public docs deliberately do not list signatures because deployed appliance arities can drift. Always find a working call site in samples/ and copy verbatim; if none exists, stop and ask. Full rules (including the appliance-as-authoritative probe and the cross-sample arity-mismatch signal) in docs/agent-reference/script-authoring-deep-dives.md.
Expression-engine and Do-block footguns
Tight rules observed across multiple platforms. Each costs an iteration if missed:
- URL-encode literal
[ and ] in any URL field — the engine rejects unescaped brackets even when the sample target accepts them.
JArray length is .Count, not .Length in %{ ... }% expressions; the wrong accessor fails silently.
ForEach.ElementName must be unique across the whole Do block. Reusing a name inside a later ForEach shadows the earlier binding; the original loop's iterator becomes stale.
- Variables set inside one
Condition.Then / Else branch are not visible in the other. Initialize the variable above the Condition if both branches need to write it.
Regex.Match(...).Groups[N].Value is the working extraction shape. Regex.Groups.Value (no index) returns nothing; cast N as an integer literal, not a string.
DiscoveryQuery requires a JSON object root — return { "items": [...] }, not a bare array.
%VaultServiceAccountKey% (ApiKey cred type) arrives base64-encoded and must be decoded in-script: %{ Encoding.UTF8.GetString(Convert.FromBase64String(VaultServiceAccountKey)) }%.
Pattern recipes
SSH operations checklist (applies to both ssh-interactive and ssh-batch)
Every SSH platform meant for asset onboarding must include a DiscoverSshHostKey operation. The appliance classifies a platform as SSH-capable by inspecting its operation set (Hercules runtime check, not the schema) and refuses host-key flows on platforms that lack it — surfaces as 60306: Platform does not support SSH authentication from New-SafeguardCustomPlatformAsset. Copy the shape from samples/ssh/generic-linux/GenericLinux.json; do not set SoftwareVersionVariableName on the command (no on-disk sample does, and the runtime silently fails on it via 60307).
ssh-interactive
Use when the target presents a shell prompt, banner, or appliance CLI; password change goes through interactive prompts (passwd); sudo may prompt.
Starter: templates/TemplateSshMinimal.json — minimum viable CheckSystem using Connect + Send + Receive. Wraps the work in Try/Catch and unconditionally Disconnects.
Closest production sample: samples/ssh/generic-linux/GenericLinux.json — full CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey. Mid-complexity sample with prompt flushing and unique success markers (e.g., INIT_CHECK=$? style).
Key shapes (verified in the sample/template above):
Connect: Type: "Ssh", RequestTerminal: true (default), NetworkAddress, Port, Login, Password/UserKey, CheckHostKey/HostKey, Timeout. The connection is named via ConnectionObjectName (e.g., "Global:ConnectSsh"); subsequent Send/Receive reference the unscoped name ("ConnectSsh").
Send writes a single line; pair with Receive using ExpectRegex to anchor on the prompt or a unique marker.
Disconnect always inside its own Try/Catch so a hung session does not mask the operation result.
Common pitfalls: unflushed banners (the first Receive after Connect is often the banner, not the prompt); over-broad ExpectRegex that matches passwd: inside an error sentence; putting Disconnect after Return.
Reference: docs/guides/ssh-platforms.md, docs/reference/commands/connect.md, docs/reference/commands/send-receive.md.
ssh-batch
Use when the target accepts ssh user@host '<command>' cleanly: stdout/stderr/exit-code returned without a PTY.
Closest production sample: samples/ssh/linux-ssh-batch-mode/LinuxSshBatchModeExample.json. The Connect block sets RequestTerminal: false (line 162) and the loop uses ExecuteCommand with BufferName, StderrBufferName, and ExitStatusBufferName (lines 205–211, 235–241).
Key shapes:
Connect: same as ssh-interactive but RequestTerminal: false.
ExecuteCommand: ConnectionObjectName, Command, Stdin (optional), BufferName for stdout, StderrBufferName, ExitStatusBufferName. Inspect the exit-status variable in a Condition block, not by parsing stderr.
CommandContainsSecret / InputContainsSecret mark whether the Command/Stdin carries a secret so SPP can redact in task logs.
Common pitfalls: assuming PTY-style behavior (interactive passwd does not work over batch mode — use chpasswd or vendor-specific batch commands); forgetting to check the exit-status buffer.
CheckPassword on Linux: pass the whole shadow line to CompareShadowHash
Use getent shadow %AccountUserName% (sudo -S in batch mode), capture the whole line, hand it to CompareShadowHash.SaltedHash whole — the component splits internally. Never pre-split in a SetItem expression: it triggers a Z.Expressions overload-ambiguity error that surfaces as a sentinel PasswordMismatch verdict. CompareShadowHash already handles yescrypt, bcrypt, SHA-512/256, MD5, AIX SSHA — there is no hash-format reason to fall back to auth-by-login. Full pattern with line refs in docs/agent-reference/script-authoring-deep-dives.md.
Catch blocks must log before falling back
Any Catch that produces a verdict (rather than re-raising) must log the caught exception via WriteResponseObject (or a Status message) before emitting the fallback value — otherwise script-side bugs surface as clean target-state verdicts and the next agent misdiagnoses them. See docs/agent-reference/script-authoring-deep-dives.md.
http-api
Use when the target exposes a documented HTTP/REST API and the script presents a credential the operator already holds (token, password, API key, anything else).
The script shape is the same regardless of auth scheme: BaseAddress → NewHttpRequest → (auth setup) → Request → ExtractJsonObject → Status. What varies is two orthogonal choices the recipe makes you spell out: auth shape and one-step vs two-step.
Pre-flight: rules every Request block must satisfy
These four rules each cost a real iteration when violated in prior testing. Check every Request block — including token-refresh and login calls inside helper functions — against all four before treating the draft as ready for local schema validation:
- TLS skip is per-
Request, not per-platform. Declare the reserved parameter SkipServerCertValidation (Type: Boolean, DefaultValue: false) in every operation's Parameters and in any function Parameters array that issues a Request, then set "IgnoreServerCertAuthentication": "%{SkipServerCertValidation}%" on every Request block. SPP auto-sources the value from the asset's VerifySslCertificate flag — no -CustomScriptParameters plumbing at onboarding. Missing it on even one block (token refresh is the common miss) re-introduces TLS failure on that call only.
- Form bodies use
SetFormValue + Content.ContentObjectName. Never Content.Value. Content.Value is undocumented and the engine re-encodes — %40 becomes %2540, + and = may be dropped — producing 400 BadRequest from targets that accept the identical body when sent manually. Mirror samples/http/twitter/CustomTwitter.json lines 133–148.
- URL path encoding uses the
UrlEncode command + SetItem, never bare Uri.EscapeDataString(...) inside %{...}%. The script-engine expression evaluator does not bind base class library types by bare name; Uri.EscapeDataString(x) parses as <var Uri>.Method(x) and fails Test-SafeguardCustomPlatformScript with the variable "Uri" is used at path "...SetItem.Value" but it is not declared in that scope. templates/Pattern-GenericRestApiBearerToken.json currently violates this at three call sites — do not copy from it without rewriting.
- Reserved parameter names beat invented ones. Before declaring any custom Boolean/string parameter, grep
docs/reference/reserved-parameters.md for the concept. SkipServerCertValidation, UseSsl, CheckHostKey, HostKey, HttpProxyUri/Port/UserName/Password, TacacsSecret and several others are auto-sourced from asset settings — declaring an invented name forces the operator to remember -CustomScriptParameters forever.
Each rule has an entry in docs/agent-reference/failure-patterns.md with full symptom text.
Auth shape — pick a bucket, then a specific scheme
The first decision is who handles the auth dance:
| Bucket | What the script does | Auth schemes |
|---|
| HttpAuth-managed | Hand SPP a username/password and an auth Type; the runtime builds the header. | Basic, Digest |
| Script-managed header | Build the header value yourself and attach it via Headers/AddHeaders. | Authorization: Bearer <token>, custom Authorization schemes (PVEAPIToken=…, Token …, vendor-specific), custom-header API keys (X-API-Key, X-Vault-Token, X-Auth-Token, …) |
HttpAuth-managed (Basic, Digest). Set per-request, not once globally; this matches the existing samples and avoids leaking the service-account credential into requests that should target the managed account.
{ "HttpAuth": {
"RequestObjectName": "SystemRequest",
"Type": "Basic",
"Credentials": { "Login": "%FuncUsername%", "Password": "%FuncPassword%" } } }
Closest production sample for Basic: samples/http/wordpress/WordPressHttp.json (lines 33–40, 78–82, 128–132). Starter template: templates/Pattern-GenericRestApiBasicAuth.json. For Digest the shape is identical with Type: "Digest"; clean self-hostable Digest targets are rare in 2025, so verify the runtime supports the scheme against the deployed appliance version before committing.
Script-managed header (Bearer, custom Authorization scheme, custom-header API key). Use Headers/AddHeaders, not HttpAuth. There is no HttpAuth Type for arbitrary header shapes.
{ "Headers": {
"RequestObjectName": "CheckRequest",
"AddHeaders": {
"Accept": "application/json",
"Authorization": "Bearer %AccessToken%" } } }
Swap Authorization: Bearer <token> for whatever the vendor actually uses. Two common variants:
- Bearer or custom
Authorization scheme (Authorization: Bearer <token>, Authorization: PVEAPIToken=user@realm!tokenid=UUID, Authorization: Token …). Closest production sample: samples/http/onelogin-jit/OneLogin_GRC_JIT_addon.json (Bearer header at lines 1228, 1361, 1510, 1672, 1834, 2002, 2135). Starter template: templates/Pattern-GenericRestApiBearerToken.json — caveat: this template uses Uri.EscapeDataString(...) at lines 371, 528, 676 and fails server validation as-is. Rewrite those call sites per pre-flight rule 3 before importing.
- Custom-header API key (
X-API-Key: %ApiKey%, X-Vault-Token: %Token%, X-Auth-Token: …). See lines 184–190 of templates/Pattern-GenericRestApiKeyRotation.json. That template also covers the CheckApiKey / ChangeApiKey operation pair for when the script must rotate the key itself; pair with docs/guides/api-key-management.md.
Whichever bucket you're in, declare the credential as Type: "Secret" in Parameters so SPP redacts it in task logs.
One-step vs two-step
Orthogonal to the bucket above:
- One-step — the operator already holds the credential the script presents on every operation call. HttpAuth-managed shapes are almost always one-step. Script-managed-header shapes are one-step when the credential is a long-lived API key or PAT.
- Two-step — the script POSTs credentials (often HttpAuth-managed
Basic with client id/secret, sometimes form-encoded) to a token endpoint, parses the response with ExtractJsonObject to capture an access token, then attaches that token via script-managed Headers on every subsequent operation call. The samples/http/onelogin-jit/ sample is the canonical example, interleaving HttpAuth Basic on the token call (lines 2275–2278) with Authorization: Bearer %AccessToken% on the operation calls.
Two-step gotcha: do not reuse the same RequestObjectName for the token-fetch and the operation calls. The two have different HttpAuth/Headers configurations and crossing them is a common source of 401s. Build a fresh NewHttpRequest for each.
Reference: docs/guides/http-platforms.md, docs/reference/commands/http-auth.md, docs/reference/commands/http-setup.md, docs/reference/commands/request.md, docs/reference/commands/json.md, docs/guides/api-key-management.md.
http-form-fill
Use when the target only has an HTML login form (no API).
Closest production sample: samples/http/facebook/CustomFacebook.json. The pattern uses ExtractFormData to walk the rendered form (lines 112, 195, 250) and Request with ContentType: "application/x-www-form-urlencoded" to submit it (lines 137, 222, 277). Cookies persist by default across requests on the same RequestObjectName.
Key shapes:
- GET the login page;
ExtractFormData to capture hidden fields (CSRF tokens, lifecycle cookies).
- Mutate the extracted form object (set username/password fields), POST it back with the right
ContentType.
- Handle multi-step flows (login → password-change page → submit) as separate
Request + ExtractFormData cycles. Do not assume a single round-trip works.
- Watch for redirects; some forms set the session cookie on a 30x response, so do not abort on redirect.
Common pitfalls: matching field names that the vendor changes between releases (treat the form structure as observed, not assumed); skipping CSRF tokens; reusing a RequestObjectName across login domains and losing cookies.
Reference: docs/guides/http-platforms.md ("Form-fill" section), docs/reference/commands/forms.md, docs/reference/commands/cookies.md, docs/quick-start/http-form-fill.md.
After authoring
- Run
Invoke-PlatformDevLoop.ps1 -SchemaOnly against the draft. Iterate on schema errors until clean.
- Cross-reference the chosen pattern's analogous sample. Note any structural divergences and surface them.
- Hand off to
safeguard-ps-operations for -ValidateOnly and onward. Do not call the appliance from this skill.
- When the trigger fails, route the task log to
task-log-analysis — do not jump straight back into editing the JSON without classifying the failure.