examples/create_request.json
{
"LastName": "User",
"FirstName": "Admin",
"SignupEmail": "someone@somewhere.com",
"Username": "someone@somewhere.com.trial01",
"Company": "Trialforce API Signup",
"Country": "US",
"TemplateId": "0TTxx0000000000"
}
examples/error_response.json
{
"_comment_validation": "Field-validation rejections (e.g. redundant/missing Edition) are returned synchronously by the create API. The CLI flattens the underlying sObject errors[] into the message string and sets result:null; the error code lands in name/code. There is no top-level result.errors[] on failure.",
"insertTimeValidationError": {
"status": 1,
"name": "INVALID_SIGNUP_OPTION",
"code": "INVALID_SIGNUP_OPTION",
"message": "Failed to create record. \nErrors:\n TemplateId and Edition cannot both be specified; send exactly one.",
"result": null
},
"missingEditionError": {
"status": 1,
"name": "INVALID_SIGNUP_OPTION",
"code": "INVALID_SIGNUP_OPTION",
"message": "Failed to create record. \nErrors:\n You must specify either an Edition or a TemplateId.",
"result": null
},
"_comment_missingOrgPerm": "Host org is not entitled to create trial orgs, so the SignupRequest entity is not exposed. The create API rejects it and the CLI emits a non-zero status with name/code NOT_FOUND (create REST path) or INVALID_TYPE (query path) and result:null. This is NOT retryable from the CLI; surface the raw error and point the user to Salesforce support.",
"missingOrgPermError": {
"status": 1,
"name": "NOT_FOUND",
"code": "NOT_FOUND",
"message": "The requested resource does not exist",
"result": null
},
"missingOrgPermViaQueryError": {
"status": 1,
"name": "INVALID_TYPE",
"code": "INVALID_TYPE",
"message": "sObject type 'SignupRequest' is not supported.",
"result": null
},
"asyncProvisioningError": {
"status": 0,
"result": {
"Id": "0SRxx0000000000",
"Status": "Error",
"CreatedOrgId": null,
"ErrorCode": "S-0007"
}
}
}
examples/success_response.json
{
"createResponse": {
"status": 0,
"result": {
"id": "0SRxx0000000000",
"success": true,
"errors": []
}
},
"_comment_polledResult": "Re-read of the SignupRequest after insert. The skill stops and reports as soon as CreatedOrgId is populated — this often happens while Status is still InProgress (provisioning finishes in the background). The user re-checks status later by org id or 0SR request id.",
"polledResult": {
"status": 0,
"result": {
"attributes": {
"type": "SignupRequest"
},
"Id": "0SRxx0000000000",
"Status": "InProgress",
"CreatedOrgId": "00Dxx0000000000",
"CreatedOrgInstance": "USA1",
"ResolvedTemplateId": "0TTxx0000000000",
"LoginUrl": null,
"AuthCode": null,
"ErrorCode": null
}
}
}
references/error_codes.md
# SignupRequest Error Codes
There are **two distinct error systems** for `SignupRequest`:
1. **Synchronous field validation** — returned immediately on `sf data create record` (non-zero `status`; the CLI puts the code in `name`/`code` and flattens the underlying sObject `errors[]` into the `message` string, with `result: null`). **These are server-side rejections**: the record *is* sent to the org, and the platform rejects it at insert time — both the SignupRequest-specific validators (`missingEdition`, `redundantTemplateId`, `INVALID_EMAIL_ADDRESS`, `INVALID_SIGNUP_COUNTRY`) and the generic UDD field constraints enforced on any sObject write (`STRING_TOO_LONG` for a value over a field's max length, `INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST` for a bad `Edition` value). Do **not** describe these as "client-side" — the CLI does not enforce field length, picklist values, or email/country format locally; the server does. Source: validator classes under `core/signup-request/java/src/sfdc/signup/validation/` (SignupRequest-specific) plus core UDD field validation (generic). The client-side pre-checks are only those the create script itself performs before calling `sf` — a dependency check (`sf`/`jq` on PATH), required-field presence, the "exactly one of `TemplateId`/`Edition`" rule, and the both-quote-styles guard — all of which exit non-zero *without* contacting the org. None of them validate a value's **content** (length, picklist membership, email/country format); that is always server-side.
2. **Asynchronous provisioning failure** — surfaced only while polling, as `result.Status = Error` with a `result.ErrorCode` (max 8 chars, prefixed). Source: `core/signup-request-api/java/src/sfdc/signup/SignupRequestErrorCodes.java`. Human-readable messages come from `LabelRef`s in the `SignupRequestErrors` label section.
The prefix table below is the **async `ErrorCode`** (system 2). The common-failures table mixes both, labeled by when each surfaces.
## Error code prefixes
| Prefix | Category |
|---|---|
| `C-` | Org-creation failure |
| `S-` | Signup-data failure |
| `T-` | Template failure |
| `SH-` | Shape failure |
| `VR-` | Version-selection failure |
| `X-0001` / `X-0002` | Fatal "should never happen" errors |
## Common failures (surfaced at insert time in the create envelope's `name`/`message`)
| Symptom | Likely cause / fix |
|---|---|
| `NOT_FOUND` ("The requested resource does not exist") or `INVALID_TYPE` ("sObject type 'SignupRequest' is not supported") | The `SignupRequest` entity is not exposed → the org is not entitled to create trial orgs. Not retryable. Surface the **raw CLI error as-is** and tell the user to **reach out to Salesforce support** to get the org enabled — do not name or diagnose the missing permission. |
| `INSUFFICIENT_ACCESS_OR_READONLY` | Entity is exposed but the *user* lacks the access to create the record — a user-perm problem; fix the user's permissions and retry. |
| `missingEdition` (`INVALID_SIGNUP_OPTION`) | Neither `TemplateId` nor `Edition` supplied — send exactly one |
| `redundantTemplateId` (`INVALID_SIGNUP_OPTION`) | Both `TemplateId` and `Edition` supplied — send only one |
| `noPartnerAccess` (`NO_PARTNER_PERMISSION`) | A partner/Trialforce edition requested but host org lacks partner/TMC perm — use a generic edition |
| clone/source + template/edition | Clone (`CloneFromOrg`) or source-org signup must not also send `TemplateId`/`Edition` |
| Invalid username (`INVALID_EMAIL_ADDRESS`) | Username not email-format, or not globally unique (duplicate) — ask for a different one and retry |
| `INVALID_SIGNUP_COUNTRY` | `Country` not a valid/allowed ISO code (embargoed or malformed) — fix and retry |
| `INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST` | `Edition` is not an accepted value for the host org's restricted `Edition` picklist (e.g. `Ultimate`, or a partner value the org can't use) — pick a valid generic edition (`Developer`, `Enterprise`, …) and retry |
| `STRING_TOO_LONG` | A text field exceeds its max length (e.g. `LastName`/`Company`/`Username` over 80 chars) — the message names the field and `max length` — shorten it and retry. Server-side, **not** a client-side check. |
| Invalid / not-found `TemplateId` | `0TT` template id wrong or not visible to host org. **Note: this is validated asynchronously, not at insert** — a well-formed but nonexistent `0TT` inserts successfully (`status: 0`) and surfaces later on read-back as `Status = Error` with a `T-` `ErrorCode` (e.g. `T-0002`). Handle it in Step 2, not as a create-time rejection. |
| `subdomainInUse` / invalid subdomain | Requested `Subdomain` taken or malformed |
| `dailyLimitExceeded` / `activeScratchLimitExceeded` | Daily/active signup rate limit hit for the host org |
## Handling in the skill
- Insert-time validation failures come back from `sf data create record` as a non-zero `status` with the code in `name`/`code` and the detail flattened into `message` (`result: null`) — report the message and stop.
- Async provisioning failures show up on polling (`sf data get record`) as `result.Status = Error` with a `result.ErrorCode` — report the code + category and stop polling.
references/signup_request_fields.md
# SignupRequest Field Reference
Trial-org creation is performed by inserting a **`SignupRequest`** sObject (key prefix `0SR`, `minApiVersion` 182). Source of truth: `core/udd-xml/java/resources/udd/SignupRequest.entity.xml` in the core repo (`gitcore.soma.salesforce.com/core-2206/core-266-public`). Owned by the **Signup and ISV Tools** team.
## Required fields
| Field | Type | Meaning / Notes |
|---|---|---|
| `LastName` | Text(80) | Admin user last name |
| `Username` | Text(80) | Admin username. Globally unique, must be email-format. Lowercased on set. |
| `SignupEmail` | Email | Admin user email |
| `Company` | Text(80) | Company / org name |
| `Country` | Text(3) | ISO country code (e.g. `US`). `DefaultSignupRequest` defaults to `US`. |
## Common optional fields
| Field | Type | Meaning |
|---|---|---|
| `FirstName` | Text | Admin first name |
| `TemplateId` | Text(15) | Trialforce Template ID (`0TT…`) — the trial product/template to clone |
| `Edition` | StaticEnum `Edition` (minApi 198) | Org edition, e.g. `Developer`, `Enterprise`/`PlanOrgEE`, `PlanOrgPE`, `PlanOrgDE`. Mutually exclusive with clone/source-org. Confirm exact API values against `udd-*.xml` if enumerating. |
| `TrialDays` | Integer | Days until trial expiry. Resolved server-side post-commit if not set. |
| `PreferredLanguage` | StaticEnum `Language` (minApi 198) | Default locale. Invalid values silently scrubbed (`scrubLanguageField`). Default `en_US`. |
| `Subdomain` | Text (minApi 186) | Requested My Domain subdomain |
| `ConnectedAppConsumerKey` | Text(120) (minApi 184) | Return an OAuth auth code for this Connected App |
| `ConnectedAppCallbackUrl` | StringPlusClob(2000) (minApi 184) | OAuth callback URL |
| `IsSignupEmailSuppressed` | Boolean (minApi 186) | Suppress welcome email. Server default applied if not explicitly set. |
| `SignupSource` | Text(60) (minApi 200) | Free-text signup source tag |
## Perm-gated / advanced fields
Require additional org/user permissions; only set when the host org is entitled.
| Field | Type | Gate |
|---|---|---|
| `IsSyncLogin` | Boolean (minApi 194) | `SignupRequestSyncLogin` perm. Synchronous login; populates `LoginUrl`. |
| `IsTso` | Boolean (minApi 200) | `isHubMasterAndPartner` / TMO |
| `ShouldConnectToEnvHub` | Boolean (minApi 198) | Env Hub membership |
| `CloneFromOrg` | Text(15) (minApi 204) | Clone from existing org (Env Hub / Sayonara) |
| `AuthProviderType` | StaticEnum (minApi 236) | `SocialSignup` perm |
| `Instance` | Text(8) (minApi 210) | `SetSignupDestination` perm — force target instance |
| `EmailBrandId` / `LoginBrandId` | Text(15) (minApi 202) | TMO-only branding |
| `ArtifactAncestors` | StringPlusClob(2000) (minApi 206) | Perm-gated artifact ancestry |
| `InternalForceSync` | Boolean (minApi 202) | Internal/test only (`isDevInternal`/`isUiTier`) |
| `TrialSourceOrgId` | Text(15) | System-set, read-only |
## System-set / read-only output fields
`createAccess="UserType.AUTOMATED_PROCESS;isDevInternal"` — you read these back from the record after create, you do not set them:
`Status`, `ErrorCode`, `CreatedOrgId`, `CreatedOrgInstance`, `ResolvedTemplateId`, `TemplateDescription`, `AuthCode`, `LoginUrl`.
## Validation rules to respect
- Send **exactly one** of `Edition` or `TemplateId`. Both → `redundantTemplateId`; neither → `missingEdition` (both under `ApiErrorCodes.INVALID_SIGNUP_OPTION`).
- `TemplateId`/`Edition` cannot coexist with clone (`CloneFromOrg`) or source-org fields.
- Partner/Trialforce editions require the host org's partner/TMC perm → otherwise `noPartnerAccess`.
- Daily / active signup rate limits (`dailyLimitExceeded`, `activeScratchLimitExceeded`).
- Invalid username (email-format + globally unique) / country (ISO code) / templateId (`0TT`) / subdomain; subdomain-in-use.
- **Terms / subscription-agreement acceptance is NOT a field on this sObject** — it is enforced at the higher-level WebForm / `SignupConfigItem` layer (public developer signup forms). This skill targets the authenticated `SignupRequest` sObject path, not the public web-form config layer.
## Authentication summary
- Not public/unauthenticated. Authenticate as a user in a **host org**.
- Org gate: the host org must be **entitled to create trial orgs**. The `SignupRequest` entity is only exposed on an entitled org.
- The skill does not run a separate entitlement check — the `sf data create record` call is the definitive gate. On an unentitled org the entity is not exposed and the create fails with `NOT_FOUND` or `INVALID_TYPE`; surface the raw CLI error and point the user to Salesforce support — do not diagnose the missing permission. (Entity accessibility does map exactly to entitlement, so a `SELECT Id FROM SignupRequest LIMIT 1` probe would work as a fail-fast check, but it only duplicates the create-time gate.)
- User gate: the invoking user must be authenticated and have sufficient access on the host org to create the record.
- Auth: log into the host org once with `sf org login web` (or `sf org login`), then reference it by username or alias with `--target-org` (`-o`) on each `sf data create record` / `sf data get record` call. The CLI carries the auth for you.
- Always pass `-o` explicitly and confirm the target host org with the user first. Without `-o` the CLI falls back to the configured default org (`target-org` config / `SF_TARGET_ORG`), or errors with `NoDefaultEnvError` if none is set — it never auto-selects among connected orgs. The default may be an unrelated org, so an omitted `-o` risks provisioning against the wrong org. Verify the chosen org shows `Connected` in `sf org list`.
scripts/create_signup_request.sh
#!/usr/bin/env bash
#
# create_signup_request.sh — Create a Salesforce trial org by inserting a
# SignupRequest sObject against an authenticated host org.
#
# This wraps the deterministic parts of the create step: enforcing the
# "exactly one of --template-id / --edition" rule, assembling and quoting the
# `sf data create record --values` payload, running the insert, and returning
# the assigned SignupRequest id (0SR...). Prose interpretation is not needed —
# the field-selection and serialization rules live here.
#
# Auth is handled by the `sf` CLI (the access token never surfaces here).
#
# Usage:
# create_signup_request.sh --target-org <alias-or-username> \
# --last-name <name> --email <addr> --username <email-format> \
# --company <name> --country <ISO> \
# ( --template-id 0TT... | --edition Developer ) \
# [--first-name <name>]
#
# Options:
# --target-org, -o <org> REQUIRED. Host org alias or username to create in.
# --last-name <name> REQUIRED. Admin user last name.
# --email <addr> REQUIRED. SignupEmail — a real inbox.
# --username <email> REQUIRED. Admin username (email-format, globally unique).
# --company <name> REQUIRED. Company / org name (spaces allowed).
# --country <ISO> REQUIRED. ISO country code (e.g. US, GB, IN).
# --template-id 0TT... Trialforce template id. Mutually exclusive with --edition.
# --edition <Edition> Generic edition (Developer, Enterprise, ...). Mutually
# exclusive with --template-id.
# --first-name <name> Optional. Admin user first name.
# --output-dir <dir> On a rejected create, write the failure outcome to
# <dir>/signup-request-result.json (so the run still
# has an artifact when no org is created).
# --json Emit the raw `sf` create envelope as-is (default:
# print the 0SR id on success, error text on failure).
# --help, -h Show this help.
#
# Output:
# On success (default): the SignupRequest id (0SR...) on stdout, exit 0.
# On success (--json): the raw `sf data create record --json` envelope.
# On failure: the raw CLI error (name/code + message) on stderr, and
# (with --output-dir) a create-rejected artifact JSON.
#
# Exit codes:
# 0 create succeeded
# 1 create rejected by the API (validation / entitlement / access error)
# 2 bad usage or missing dependency (sf or jq)
set -uo pipefail
die() { echo "Error: $*" >&2; exit 2; }
command -v sf >/dev/null 2>&1 || die "Salesforce CLI ('sf') not found on PATH."
command -v jq >/dev/null 2>&1 || die "'jq' not found on PATH."
TARGET_ORG=""
LAST_NAME=""
EMAIL=""
USERNAME=""
COMPANY=""
COUNTRY=""
TEMPLATE_ID=""
EDITION=""
FIRST_NAME=""
OUTPUT_DIR=""
OUTPUT="id"
usage() { sed -n '2,/^set -uo/p' "$0" | sed '/^set -uo/d; s/^# \{0,1\}//; s/^#//'; }
while [ $# -gt 0 ]; do
case "$1" in
--target-org|-o) TARGET_ORG="${2:-}"; shift 2 ;;
--last-name) LAST_NAME="${2:-}"; shift 2 ;;
--email) EMAIL="${2:-}"; shift 2 ;;
--username) USERNAME="${2:-}"; shift 2 ;;
--company) COMPANY="${2:-}"; shift 2 ;;
--country) COUNTRY="${2:-}"; shift 2 ;;
--template-id) TEMPLATE_ID="${2:-}"; shift 2 ;;
--edition) EDITION="${2:-}"; shift 2 ;;
--first-name) FIRST_NAME="${2:-}"; shift 2 ;;
--output-dir) OUTPUT_DIR="${2:-}"; shift 2 ;;
--json) OUTPUT="json"; shift ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown argument: $1" ;;
esac
done
# --- required-field validation ------------------------------------------
[ -n "$TARGET_ORG" ] || die "--target-org is required (host org alias or username)."
[ -n "$LAST_NAME" ] || die "--last-name is required."
[ -n "$EMAIL" ] || die "--email is required."
[ -n "$USERNAME" ] || die "--username is required."
[ -n "$COMPANY" ] || die "--company is required."
[ -n "$COUNTRY" ] || die "--country is required (ISO code, e.g. US)."
# --- exactly-one-of rule (TemplateId XOR Edition) -----------------------
if [ -n "$TEMPLATE_ID" ] && [ -n "$EDITION" ]; then
die "Send exactly one of --template-id or --edition, not both (would fail with redundantTemplateId)."
fi
if [ -z "$TEMPLATE_ID" ] && [ -z "$EDITION" ]; then
die "Send exactly one of --template-id or --edition (neither given would fail with missingEdition)."
fi
# --- reject values 'sf --values' cannot represent -----------------------
# A value containing BOTH a single and a double quote cannot be expressed in the
# space-separated --values string, whose parser honors only one quote style per
# token. Check here in the main scope: a guard inside sf_quote would run inside
# $(...) command substitution, where `exit` kills only the subshell and (with no
# `set -e`) the script would carry on and issue a malformed create.
for v in "$LAST_NAME" "$EMAIL" "$USERNAME" "$COMPANY" "$COUNTRY" \
"$FIRST_NAME" "$TEMPLATE_ID" "$EDITION"; do
case "$v" in
*\'*\"* | *\"*\'*) die "Value contains both single and double quotes, which 'sf --values' cannot represent: $v" ;;
esac
done
# --- assemble the --values payload --------------------------------------
# `sf data create record --values` takes ONE space-separated string of
# Field=Value pairs and re-splits it on whitespace, honoring only quotes that
# appear *inside* that string (see plugin-data's stringToDictionary parser).
# So any value containing whitespace (e.g. Company="Acme Corporation") must be
# quoted within the payload, or sf mis-parses it into separate tokens. Quote
# each value: single-quote by default; switch to double quotes when the value
# itself contains a single quote (e.g. a last name like O'Brien). Values with
# both quote types are already rejected above.
sf_quote() {
case "$1" in
*\'*) printf '"%s"' "$1" ;;
*) printf "'%s'" "$1" ;;
esac
}
PAIRS=(
"LastName=$(sf_quote "$LAST_NAME")"
"SignupEmail=$(sf_quote "$EMAIL")"
"Username=$(sf_quote "$USERNAME")"
"Company=$(sf_quote "$COMPANY")"
"Country=$(sf_quote "$COUNTRY")"
)
[ -n "$FIRST_NAME" ] && PAIRS+=("FirstName=$(sf_quote "$FIRST_NAME")")
[ -n "$TEMPLATE_ID" ] && PAIRS+=("TemplateId=$(sf_quote "$TEMPLATE_ID")")
[ -n "$EDITION" ] && PAIRS+=("Edition=$(sf_quote "$EDITION")")
# Join the quoted pairs into the single space-separated string sf expects.
VALUES_STR="${PAIRS[*]}"
# --- run the insert ------------------------------------------------------
RESP="$(sf data create record --target-org "$TARGET_ORG" --sobject SignupRequest \
--values "$VALUES_STR" --json 2>/dev/null)"
STATUS="$(printf '%s' "$RESP" | jq -r '.status // 1' 2>/dev/null)"
if [ "$STATUS" != "0" ]; then
# Surface the raw CLI error verbatim (name/code + message) — do not diagnose.
NAME="$(printf '%s' "$RESP" | jq -r '.name // .code // "ERROR"')"
MSG="$(printf '%s' "$RESP" | jq -r '.message // "unknown error"')"
printf '%s: %s\n' "$NAME" "$MSG" >&2
# No org is created on a rejected create, so get_signup_request.sh never runs.
# Still leave the run an artifact when --output-dir is given: capture the
# create-rejected outcome and the raw error verbatim (no diagnosis).
if [ -n "$OUTPUT_DIR" ]; then
mkdir -p "$OUTPUT_DIR"
jq -n --arg name "$NAME" --arg msg "$MSG" \
'{outcome: "create-rejected", error: {name: $name, message: $msg}, CreatedOrgId: null, Status: null}' \
> "$OUTPUT_DIR/signup-request-result.json"
fi
exit 1
fi
if [ "$OUTPUT" = "json" ]; then
printf '%s\n' "$RESP"
else
printf '%s\n' "$(printf '%s' "$RESP" | jq -r '.result.id')"
fi
scripts/get_signup_request.sh
#!/usr/bin/env bash
#
# get_signup_request.sh — Read back a SignupRequest to pick up the assigned
# trial org id, applying a fixed polling policy, and (optionally) write the
# record as the skill's output artifact.
#
# Trial-org provisioning is asynchronous: the insert only returns the 0SR
# handle, while CreatedOrgId / Status populate shortly after. This script
# encapsulates the deterministic policy so every invocation behaves the same:
# - read the record;
# - stop as soon as CreatedOrgId is populated (this typically happens while
# Status is still InProgress — the caller need not wait for Success);
# - otherwise re-read up to --max-attempts times TOTAL, sleeping --delay
# seconds between reads, then return the current state (no infinite loop);
# - stop immediately on a terminal Status (Success or Error) or a CLI error.
#
# It always prints the resolved record (its `result` object) as JSON on stdout.
# With --output-dir it also writes that JSON to <dir>/signup-request-result.json.
#
# Auth is handled by the `sf` CLI (the access token never surfaces here).
#
# Usage:
# get_signup_request.sh --target-org <org> --id <0SR...> [--output-dir <dir>]
# get_signup_request.sh --target-org <org> --id <0SR...> --max-attempts 3 --delay 20
#
# Options:
# --target-org, -o <org> REQUIRED. Host org alias or username.
# --id, -i <0SR...> REQUIRED. SignupRequest id to read.
# --output-dir <dir> Write the record JSON to <dir>/signup-request-result.json.
# --max-attempts <n> TOTAL reads before giving up on an empty CreatedOrgId
# (1 to 3; default 3). Provisioning is async, so we do
# not poll indefinitely: at most three lookups, then
# hand back the request id (exit 3) for the user to
# re-check.
# --delay <seconds> Sleep between reads (default 20).
# --help, -h Show this help.
#
# Output:
# The SignupRequest record (the `sf` envelope's `result`) as JSON on stdout.
#
# Exit codes:
# 0 CreatedOrgId populated (org allocated) — the only exit that carries an org id
# 1 Status=Error, or the record could not be read (auth / id not found)
# 2 bad usage or missing dependency (sf or jq)
# 3 no org id available — record read but CreatedOrgId still empty (still
# provisioning after --max-attempts, or a terminal status with no id).
# Not an error. The caller reports the 0SR id and the record's REAL Status,
# and tells the user to re-check later. The exit code never reflects a
# synthesized status — only what is actually on the record.
set -uo pipefail
die() { echo "Error: $*" >&2; exit 2; }
command -v sf >/dev/null 2>&1 || die "Salesforce CLI ('sf') not found on PATH."
command -v jq >/dev/null 2>&1 || die "'jq' not found on PATH."
TARGET_ORG=""
SR_ID=""
OUTPUT_DIR=""
MAX_ATTEMPTS=3
DELAY=20
usage() { sed -n '2,/^set -uo/p' "$0" | sed '/^set -uo/d; s/^# \{0,1\}//; s/^#//'; }
while [ $# -gt 0 ]; do
case "$1" in
--target-org|-o) TARGET_ORG="${2:-}"; shift 2 ;;
--id|-i) SR_ID="${2:-}"; shift 2 ;;
--output-dir) OUTPUT_DIR="${2:-}"; shift 2 ;;
--max-attempts) MAX_ATTEMPTS="${2:-}"; shift 2 ;;
--delay) DELAY="${2:-}"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown argument: $1" ;;
esac
done
[ -n "$TARGET_ORG" ] || die "--target-org is required."
[ -n "$SR_ID" ] || die "--id is required (the 0SR... SignupRequest id)."
case "$MAX_ATTEMPTS" in 1|2|3) ;; *) die "--max-attempts must be 1, 2, or 3 (provisioning is async; we do not poll more than three times)." ;; esac
case "$DELAY" in ''|*[!0-9]*) die "--delay must be a non-negative integer." ;; esac
# read the record once; sets RESULT (the `result` object) and RECORD_STATUS,
# or returns non-zero on a CLI error (RESULT holds the raw error envelope).
read_record() {
local resp env_status
resp="$(sf data get record --target-org "$TARGET_ORG" --sobject SignupRequest \
--record-id "$SR_ID" --json 2>/dev/null)"
env_status="$(printf '%s' "$resp" | jq -r '.status // 1' 2>/dev/null)"
if [ "$env_status" != "0" ]; then
printf '%s' "$resp" | jq -r '"\(.name // .code // "ERROR"): \(.message // "unknown error")"' >&2
return 1
fi
RESULT="$(printf '%s' "$resp" | jq '.result')"
RECORD_STATUS="$(printf '%s' "$RESULT" | jq -r '.Status // ""' | tr '[:upper:]' '[:lower:]')"
CREATED_ORG_ID="$(printf '%s' "$RESULT" | jq -r '.CreatedOrgId // ""')"
return 0
}
RESULT=""
RECORD_STATUS=""
CREATED_ORG_ID=""
FINAL_RC=0
# At most MAX_ATTEMPTS total reads (1 to 3). Provisioning is async, so if the
# org id has not appeared by the final read we stop and hand back the request
# id rather than polling on — the caller reports 0SR and asks the user to
# re-check later (exit 3).
attempt=0
while : ; do
if ! read_record; then
exit 1
fi
attempt=$((attempt + 1))
# Stop as soon as the org is allocated, or on a terminal status. Success and
# Error are both terminal — there is nothing to gain by re-reading either, so
# neither consumes the retry budget. The exit code is driven by what is
# actually on the record, never by a synthesized value:
# - CreatedOrgId present -> 0 (org allocated; caller shares the id)
# - Status=Error -> 1 (provisioning failed)
# - terminal but no id (e.g. odd
# Success with empty CreatedOrgId) -> 3 (no id to give; report real Status)
if [ -n "$CREATED_ORG_ID" ]; then
FINAL_RC=0
break
fi
if [ "$RECORD_STATUS" = "error" ]; then
FINAL_RC=1
break
fi
if [ "$RECORD_STATUS" = "success" ]; then
# Terminal, but the org id never populated — stop (nothing to re-read) and
# hand back the request id with the record's real Status.
FINAL_RC=3
break
fi
# CreatedOrgId still empty and status not yet terminal. Give up once we've
# used our lookup budget.
if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
FINAL_RC=3
break
fi
sleep "$DELAY"
done
# Always emit the record as JSON on stdout.
printf '%s\n' "$RESULT"
# Defined output artifact: write the record when an output dir is given.
if [ -n "$OUTPUT_DIR" ]; then
mkdir -p "$OUTPUT_DIR"
printf '%s\n' "$RESULT" > "$OUTPUT_DIR/signup-request-result.json"
fi
exit "$FINAL_RC"
SKILL.md
---
name: platform-trial-org-create
description: "Use this skill to create a Salesforce trial, developer, or Trialforce org against an already-authenticated host org, the same way a developer/Trialforce web signup form provisions one. INVOKE when the user asks to: create a trial org, sign up a new trial or developer org, provision a Trialforce org from a template, or check the status of a trial-org signup they started. Trigger phrases: 'create a trial org', 'sign up a trial org', 'spin up a dev org', 'Trialforce signup', 'provision trial from template', 'check my trial org signup status'. Do NOT use for scratch orgs (use dx-org-manage) or for checking trial expiration dates of existing orgs (use dx-org-trial-expiration-check)."
metadata:
version: "1.0"
domains: ["Platform"]
minApiVersion: "60.0"
relatedSkills:
- "dx-org-manage"
- "dx-org-trial-expiration-check"
cliTools:
- tool: ["jq"]
semver: ">=1.6"
- tool: ["sf"]
semver: ">=2.0.0"
---
## What this skill does
Creates a Salesforce **trial org** by inserting a `SignupRequest` sObject with the `sf` CLI — the same request the Trialforce/developer **web signup form** issues under the hood. There is no bespoke signup endpoint: signup = inserting a `SignupRequest` (key prefix `0SR`) against an authenticated **host org** (a Trialforce Source Org / Env Hub / partner org that is entitled to create trial orgs).
The flow is **two CLI calls**: (1) create the `SignupRequest`, (2) read it back to pick up the assigned org id (creation is asynchronous — the org id appears shortly after the insert).
## Prerequisites — confirm before executing
1. **Authenticated host org — always confirm which one, explicitly.** You are signing up *from* an authenticated host org, not anonymously. The `sf` CLI operates against an org you have already logged into once (`sf org login web`, or `sf org login`).
**Always pass an explicit `--target-org` (`-o`) with the host org's alias or username on every command, and confirm the target with the user before creating** — even if a default org is configured. Creating a `SignupRequest` is a real provisioning action; do not let it run against whatever org happens to be the default.
- **Do not rely on the default-org fallback.** With no `-o`, the CLI resolves the target from `--target-org` → `SF_TARGET_ORG` env var → local then global `target-org` config, and **errors (`NoDefaultEnvError`) if none is set** — it never auto-picks among your connected orgs. That default may be an unrelated dev/scratch org, so an omitted `-o` is either wrong-org or a hard failure. Never omit it.
- **The user may have many authenticated orgs.** Run `sf org list` and, if the intended host org is ambiguous or not provided, ask the user which alias/username to use. Do not guess.
- **Verify the chosen org is `Connected`** in `sf org list` before creating (stale refresh tokens / expired certs show as error states, not `Connected`).
- Never invent credentials.
2. **Host org must be entitled to create trial orgs**, and the invoking user must have sufficient access on it. The skill does **not** run a separate permission check — the `sf data create record` call (Step 1) is the definitive gate, and the same API enforcement is what a check would rely on. If the org is not entitled, the `SignupRequest` entity is not exposed and the create fails with a non-zero `status` and `name`/`code` of `NOT_FOUND` ("The requested resource does not exist") or `INVALID_TYPE` ("sObject type 'SignupRequest' is not supported"). This case is handled in Step 1's error table.
When it happens, **stop** (it is not retryable from the CLI) and report to the user: (a) the **raw CLI error as-is** — the exact `name`/`errorCode` and `message` the CLI returned, verbatim — and (b) that they should **reach out to Salesforce support** to get the org enabled for trial-org creation, then try again. **Do NOT diagnose or name which permission is missing** — just surface the raw error and point them to support.
## Required Inputs — collect from the invoking user before any create
Prompt the user for these and do NOT proceed until all are provided. Do not invent values. **Ask for each one; if the user is unsure about a field, guide them using the "If the user is unsure" column before moving on.**
**Always required (5)** — these are `required="true"` on the `SignupRequest` entity (`FirstName` is listed here for prompting convenience but is **optional**):
| Input | Notes | If the user is unsure |
|-------|-------|-----------------------|
| `LastName` | Admin user's last name. Max 80 chars. | Any surname for the new org's admin user; it's just the admin contact name, use theirs. |
| `FirstName` | **Optional.** Admin user's first name. Ask for it, but proceed without it if the user doesn't provide one. | Optional — leave blank if unsure; only `LastName` is required for the admin user. |
| `Username` | Admin login username. Must be **email-format** and **globally unique** across all Salesforce orgs. Max 80 chars. Lowercased on save. | It does not have to be a real inbox — it just has to look like an email and be unique. Suggest a pattern like `admin@<company>-<something-unique>.com`. If it collides, you'll get a duplicate-username error on create; pick another. |
| `SignupEmail` | Admin user's **real** email address (welcome/login mail goes here). | This one must be a working inbox they can access — unlike `Username`, it should be a real address. |
| `Company` | Company / org name. Max 80 chars. | The organization name to show in the trial org; any descriptive name is fine. |
| `Country` | **ISO country code**, max 3 chars, e.g. `US`, `GB`, `IN`, `DE`. Validated at runtime against allowed codes (embargoed/invalid codes are rejected). | Use the 2-letter ISO code for their country (e.g. `US` for United States, `GB` for United Kingdom). Not a free-text country name. |
**Exactly one of (required, pick one — NOT both):**
| Input | Notes | If the user is unsure |
|-------|-------|-----------------------|
| `TemplateId` | Trialforce template ID (key prefix `0TT`, 15 chars) — defines the trial org's product/content. | Use a template when they want a specific pre-built product/content set. To find available templates, query the host org: `sf data query -o <HOST_ORG> -q "SELECT Id, TemplateName FROM TrialforceTemplate" --json`. If they just want a plain trial org, use `Edition` instead. |
| `Edition` | Org edition for a generic (non-template) trial. Generic values: `Developer`, `Group`, `Professional`, `Enterprise` (also `ServiceProfessional`, `SalesEnterprise`). Partner/Trialforce editions are perm-gated. | If they just want "a dev org to try things," use `Developer`. Partner editions (`PARTNER_*`, `TRIALFORCE_*`) only work if the host org has partner/TMC perms — using one without the perm returns a `noPartnerAccess` error. |
Ask the user to choose **either** a `TemplateId` **or** an `Edition`, not both:
- **Neither supplied** → stop and ask. A create with no template and no edition fails validation with `missingEdition` (`ApiErrorCodes.INVALID_SIGNUP_OPTION`).
- **Both supplied** → ask them to pick one; send only the chosen field. Combining them fails with `redundantTemplateId`. Neither may be combined with clone/source-org fields either.
This skill intentionally scopes user-collected input to the fields above (the 5 always-required plus optional `FirstName`). Do **not** prompt for or surface other fields. The `SignupRequest` entity supports additional optional and perm-gated fields (`TrialDays`, `Subdomain`, `PreferredLanguage`, `SignupSource`, the OAuth-return pair, etc.); these are **out of scope here** and left to server defaults. They are documented in `references/signup_request_fields.md` for reference only — do not send them from this skill.
## Step 1 — Create the SignupRequest
Invoke the create script with the collected inputs. It enforces the "exactly one of `TemplateId`/`Edition`" rule, assembles and quotes the `--values` payload, runs the insert, and prints the assigned `0SR…` id on success. Reference the script by its **absolute path** from the skill directory (`<skill_dir>/scripts/…`) — never `./scripts/`, which resolves against the user's working directory.
**With a template:**
```bash
SR_ID=$(bash "<skill_dir>/scripts/create_signup_request.sh" \
--target-org <HOST_ORG> \
--last-name <LAST_NAME> --email <EMAIL> --username <UNIQUE_USERNAME> \
--company "<COMPANY>" --country <ISO> --template-id 0TT... \
--output-dir force-app/main/adk-eval-output)
```
**With an edition (generic trial, no template):** replace `--template-id 0TT...` with `--edition Developer` (or `Enterprise`, etc.). Add `--first-name <NAME>` only if the user supplied it — no other optional fields are sent by this skill. The script rejects supplying both `--template-id` and `--edition`, or neither.
Pass `--output-dir` (use `force-app/main/adk-eval-output` when it exists) so that if the create is **rejected**, the script still writes `<output-dir>/signup-request-result.json` capturing the create-rejected outcome and the raw error verbatim — the run's output artifact even when no org is created. On success this write is done by the Step 2 read-back instead.
On success the script prints the `0SR…` SignupRequest id (capture it as `SR_ID`). Pass `--json` instead to get the raw `sf` create envelope, which wraps a **handle**, not the org:
```json
{ "status": 0, "result": { "id": "0SRxx0000000000", "success": true, "errors": [] } }
```
**Handle create errors (synchronous field validation).** On a rejected create the script exits non-zero and prints the **raw CLI error** (`name`/`code` + `message`) to stderr — surface it verbatim and act per the table below. When `--output-dir` was given, the script also writes the create-rejected artifact (`{ "outcome": "create-rejected", "error": {…}, "CreatedOrgId": null, "Status": null }`) to `<output-dir>/signup-request-result.json`; do not hand-author this file. This is field validation, returned *immediately* — distinct from the async `ErrorCode` in Step 2. These are **server-side** rejections (the record reaches the org and the platform rejects it at insert time) — do not describe them to the user as "client-side"; the CLI does not validate email/country format, picklist values, or field length locally. Do NOT poll a create that failed, and do NOT proceed to Step 2.
| Failure | Meaning → what to tell the user |
|---------|---------------------------------|
| `missingEdition` / `INVALID_SIGNUP_OPTION` | Neither `TemplateId` nor `Edition` was sent — ask for one and retry. |
| `redundantTemplateId` | Both `TemplateId` and `Edition` were sent — drop one and retry. |
| `noPartnerAccess` / `NO_PARTNER_PERMISSION` | A partner/TSO edition was requested but the host org lacks the perm — use a generic edition (`Developer`, etc.) or get the perm. |
| duplicate / invalid `Username` (`INVALID_EMAIL_ADDRESS`) | `Username` is not email-format or not globally unique — ask for a different one and retry. |
| `INVALID_SIGNUP_COUNTRY` | `Country` is not a valid/allowed ISO code — fix and retry. |
| `INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST` | `Edition` is not an accepted value for the host org's restricted picklist (e.g. `Ultimate`) — pick a valid generic edition and retry. |
| `STRING_TOO_LONG` | A field value exceeds its max length (the message names the field + `max length`, e.g. `LastName` over 80) — shorten it and retry. |
| `subdomainInUse` / invalid subdomain | Chosen `Subdomain` is taken or invalid — pick another. |
| `NOT_FOUND` ("The requested resource does not exist") or `INVALID_TYPE` ("sObject type 'SignupRequest' is not supported") | The `SignupRequest` entity is not exposed → the org is not entitled to create trial orgs. **Not** retryable. Stop and surface the **raw CLI error as-is** (`name`/`errorCode` + `message`), then tell the user to **reach out to Salesforce support** to get the org enabled. **Do not name or diagnose the missing permission.** |
| `INSUFFICIENT_ACCESS_OR_READONLY` | The entity is exposed but the *user* lacks the access to create the record — a user-permission problem, distinct from the org-entitlement failure above. Fix the user's permissions and retry. |
For the full catalog and prefixes → `references/error_codes.md`.
- For the full required/optional field list, types, and perm-gated fields → load `references/signup_request_fields.md`.
- Do NOT send `TemplateId` together with clone/source-org fields (`redundantTemplateId` error). Do NOT request a partner/Trialforce edition without the host org's partner/TMC perm.
## Step 2 — Read the request once the org id is available (creation is async)
Provisioning happens asynchronously after the insert, so re-read the record to pick up the assigned org id. Invoke the read script — it applies a fixed, bounded read-back policy internally (stopping as soon as `CreatedOrgId` is populated or the status is terminal, and never polling indefinitely), prints the record as JSON, and writes the output artifact when `--output-dir` is given. Then act on the script's **exit code** (below) — the retry count and delay are the script's own deterministic logic; you do not re-implement or re-count them in prose. If the org id is not yet available, the script exits `3` so you can hand the request id back to the user:
```bash
bash "<skill_dir>/scripts/get_signup_request.sh" \
--target-org <HOST_ORG> --id "$SR_ID" [--output-dir <DIR>]
```
The script prints the `SignupRequest` record (the `sf` envelope's `result`) as JSON. Read these fields from it:
- `CreatedOrgId` — the new trial org id (`00D…`, 15 chars). Populated as soon as the org is allocated (often while `Status` is still `InProgress`); this is the script's stop signal.
- `CreatedOrgInstance` — instance hosting the new org (target follow-up calls here).
- `Username` — the admin login username on the record. Report the value **read back from the record**, not the raw input — it is lowercased on save, so the stored value is the accurate one to hand the user.
- `Status` — lifecycle `New` → `InProgress` → `Success` | `Error` (match case-insensitively).
- `LoginUrl` — present only if `IsSyncLogin` was set on create (perm-gated).
- `AuthCode` — present only if `ConnectedAppConsumerKey` + `ConnectedAppCallbackUrl` were set.
- `ErrorCode` — populated **only when `Status = Error`**. This is the *async provisioning* error (distinct from the synchronous create-time validation in Step 1), prefixed:
- `C-` org creation error · `S-` signup data error · `T-` template error (e.g. `T-0002` = template not found) · `SH-` org-shape error · `VR-` version-selection error · `X-0001`/`X-0002` fatal/should-never-happen.
Act on the script's exit code. **The org id lookup runs first — do not report anything to the user until the script returns.** Report only what is actually on the record; never invent or relabel the `Status`:
- **`0`** — `CreatedOrgId` is populated. Proceed to Step 3 and report the org id together with the record's real `Status` and the `Username`.
- **`3` — no org id available yet (not an error).** The org has not been allocated. The script has already exhausted its bounded read-back — **do NOT re-invoke it in a loop to keep polling.** Report the **SignupRequest id (`0SR…`)**, the record's **`Status` exactly as returned**, and the `Username`; tell the user the org id is not available yet, and let them re-check later (see Step 3).
- **`1`** — `Status = Error` (stop and report the `ErrorCode` and its prefix meaning via `references/error_codes.md`; the org was not created), or the read itself failed (auth expired, `0SR` id not found) — surface the raw CLI error and stop.
## Step 3 — Report details and hand off status checks
Report **after** the Step 2 lookup returns — not before. When the user asks to create (or re-check) the org, run the read-back first and wait for it, then report all available details to the user in one go. Report **only what is on the record**; never fabricate or relabel a value — especially `Status`, which must be the exact string the response object carries:
- **SignupRequest id** — the `0SR…` request id (from Step 1). **Always report this** — it is the handle the user (or a later check) uses to look the request up again, and it is the primary thing to hand back if the org id is not yet available.
- **CreatedOrgId** — the new trial org id (`00D…`), when populated. If the read returned exit `3` (org id not yet available), say so plainly: the request was accepted and the org is still being provisioned; there is no org id to share *yet*.
- **CreatedOrgInstance** — the instance hosting it, if present.
- **Username** — the admin login username **as stored on the record** (lowercased on save). Always report this — it is what the user logs in with once the org is ready.
- **Status** — the `Status` value **exactly as it appears on the record**. Echo whatever string the response carries; do not map, translate, infer, or pick from a fixed list. A still-pending status is normal at this point — provisioning finishes in the background.
- **LoginUrl** / **AuthCode** — only if present.
Also tell the user that **login details for the new org arrive by email** — once provisioning completes, a welcome/login email is sent to the `SignupEmail` address, so they should watch that inbox to finish logging in. (This is why `SignupEmail` must be a real, accessible address.)
Then suggest, in plain language, how the user can re-check status later — provisioning may still be completing. Tell them they can just ask (the skill re-reads the record), for example:
- "Check the status of my trial org **`00D…`**" (by the org id), or
- "Check the status of signup request **`0SR…`**" (by the SignupRequest id).
Both resolve to a re-run of the Step 2 read script against the same `SignupRequest` record (the org id is looked up on that record). A later check should report the current `Status` **exactly as returned** on the record. If the record comes back with an error status, also report the `ErrorCode` and its prefix meaning via `references/error_codes.md`.
**Output artifact.** Write the current `SignupRequest` record as the run's output artifact by passing `--output-dir` to the Step 2 read script — the script writes `<output-dir>/signup-request-result.json` (creating the directory if needed). Use `force-app/main/adk-eval-output` as the output directory when it exists:
```bash
bash "<skill_dir>/scripts/get_signup_request.sh" \
--target-org <HOST_ORG> --id "$SR_ID" \
--output-dir force-app/main/adk-eval-output
```
This is the run's defined output — do not ask permission before writing it.
## Reference File Index
| File | When to read |
|------|-------------|
| `references/signup_request_fields.md` | Full field reference — required/optional fields, types, defaults, perm-gated fields, editions |
| `references/error_codes.md` | Interpreting `ErrorCode` prefixes and common validation failures |
## Example Files
Load these only when you need to see the concrete shape of a payload or response — they are illustrative samples with placeholder ids (`0SRxx…`, `00Dxx…`), not values to send.
| File | When to read |
|------|-------------|
| `examples/create_request.json` | When assembling the create — to confirm the field names/shape of the `SignupRequest` create payload |
| `examples/success_response.json` | When interpreting a successful create + read-back — shows the record once `CreatedOrgId` is populated |
| `examples/error_response.json` | When interpreting a rejected create — shows the shape of common validation/error responses |