Examples
Every step as a curl command, the same flow as one Python file, and the command line client for people who would rather not write either.
curl, step by step
All samples share the first block: the base URL and an X-API-Key header taken from an environment variable. They need curl and jq; the upload samples also use zip.
Credential check
# Put the key in an environment variable, loaded from your secret store. Do not paste it into a script.
# Every call uses curl --fail-with-body (curl 7.76 or newer): an HTTP error makes curl exit non-zero and still prints
# or saves the API's error body. When you run these samples as a script, start it with: set -euo pipefail
export MYRLIN_FORGE_API_KEY="..."
BASE=https://api.myrlin.io/api/pipeline/v1
AUTH="X-API-Key: $MYRLIN_FORGE_API_KEY"
# Check the credential and read what it may do.
curl -sS --fail-with-body -H "$AUTH" "$BASE/me"Create the project
# Create the project that the manifest's project.slug names.
curl -sS --fail-with-body -X PUT "$BASE/projects/example_pack" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name": "Example pack", "namespace": "examplemod"}'Upload inputs
Needs inputs:write and uses the upload bucket. Skip this step when the manifest has no input files.
# Zip your reference art and sources (with an optional _inputs.json for rights), then upload the ZIP.
(cd inputs && zip -r ../inputs.zip .)
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/inputs" \
-H "$AUTH" -F "file=@inputs.zip;type=application/zip" > inputs.json
INPUT_SET=$(jq -r .inputSetId inputs.json)
echo "$INPUT_SET"Lint
Where the streamlining flag is off this answers 404 not_found; go straight to the quote, which reports the same issues.
# Check the manifest and the uploaded inputs. A bad manifest is still HTTP 200 with "valid": false.
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/lint" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"manifest\": $(cat manifest.json), \"inputSetId\": \"$INPUT_SET\"}" | jq '{valid, issues}'Quote
# Plan the batch and price it. Nothing is created or charged.
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/batches/quote" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"manifest\": $(cat manifest.json)}" > quote.json
jq '{totalCredits, planSha256, approvable, issues}' quote.jsonSubmit
# Approve exactly this quote, with a ceiling on credits and an idempotency key.
PLAN=$(jq -r .planSha256 quote.json)
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/batches" \
-H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: example-pack-r1" \
-d "{\"manifest\": $(cat manifest.json), \"planSha256\": \"$PLAN\", \"maxCredits\": 0}" > batch.json
BATCH=$(jq -r .batchId batch.json)
echo "$BATCH"Wait
# Long poll until the batch ends. Each call waits up to 30 seconds for a new event.
AFTER=0
while true; do
curl -sS --fail-with-body -H "$AUTH" "$BASE/batches/$BATCH/wait?after=$AFTER&timeout=30" > wait.json
AFTER=$(jq -r .nextAfter wait.json)
STATUS=$(jq -r .status wait.json)
case "$STATUS" in
queued|running|packaging|canceling) ;;
*) break ;;
esac
done
echo "$STATUS"Download and verify
# Download the result and check the ZIP against the hash in result.json before installing anything.
curl -sS --fail-with-body -H "$AUTH" -o result.json "$BASE/batches/$BATCH/result.json"
curl -sS --fail-with-body -H "$AUTH" -o forge-asset-pack.zip "$BASE/batches/$BATCH/result.zip"
echo "expected: $(jq -r .zip.sha256 result.json)"
sha256sum forge-asset-pack.zipStatus, events, receipt, link, cancel
# Status, the event log, the cost receipt and a short-lived download link.
curl -sS --fail-with-body -H "$AUTH" "$BASE/batches/$BATCH"
curl -sS --fail-with-body -H "$AUTH" "$BASE/batches/$BATCH/events?after=0"
curl -sS --fail-with-body -H "$AUTH" "$BASE/batches/$BATCH/receipt"
curl -sS --fail-with-body -H "$AUTH" "$BASE/batches/$BATCH/result-url"
# Cancel a batch that is still active.
curl -sS --fail-with-body -X POST -H "$AUTH" "$BASE/batches/$BATCH/cancel"Evidence and review
Needs reviews:write. The review document follows review.schema.json.
# Upload screenshots and logs, then submit a review that cites them. review.json follows review.schema.json.
(cd evidence && zip -r ../evidence.zip .)
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/evidence" \
-H "$AUTH" -F "file=@evidence.zip;type=application/zip" > evidence.json
EVIDENCE_SET=$(jq -r .evidenceSetId evidence.json)
curl -sS --fail-with-body -X POST "$BASE/projects/example_pack/reviews?evidenceSetId=$EVIDENCE_SET" \
-H "$AUTH" -H "Content-Type: application/json" --data-binary @review.jsonPython, standard library only
The whole flow in one file. It stops with the API's error code on any failed call and refuses to write a ZIP whose hash differs from result.json.
"""Minimal pipeline client: create a project, quote, submit, wait, download. Standard library only."""
import hashlib
import json
import os
import urllib.error
import urllib.request
BASE = "https://api.myrlin.io/api/pipeline/v1"
KEY = os.environ["MYRLIN_FORGE_API_KEY"]
RUNNING = ("queued", "running", "packaging", "canceling")
def call(method, path, body=None, headers=None):
"""Send one request and return the raw response bytes. Error replies stop the script with the API's code."""
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(BASE + path, data=data, method=method, headers={
"X-API-Key": KEY, "Content-Type": "application/json", **(headers or {})})
try:
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
except urllib.error.HTTPError as error:
body = error.read() or b"{}"
try:
detail = json.loads(body).get("detail", {})
except (ValueError, AttributeError): # not JSON or not an object, such as a proxy's error page
detail = {"message": body[:200].decode("utf-8", "replace")}
if not isinstance(detail, dict): # a plain string or a list of field errors
detail = {"message": json.dumps(detail)[:300]}
raise SystemExit(f"HTTP {error.code} {detail.get('error')}: {detail.get('message')}")
def call_json(method, path, body=None, headers=None):
return json.loads(call(method, path, body, headers))
with open("manifest.json", encoding="utf-8") as source:
manifest = json.load(source)
slug = manifest["project"]["slug"]
call_json("PUT", f"/projects/{slug}", {"name": manifest["project"]["name"], "namespace": manifest["namespace"]})
quote = call_json("POST", f"/projects/{slug}/batches/quote", {"manifest": manifest})
if not quote["approvable"]:
raise SystemExit(json.dumps(quote["issues"], indent=2))
batch = call_json("POST", f"/projects/{slug}/batches", {
"manifest": manifest, "planSha256": quote["planSha256"], "maxCredits": quote["totalCredits"],
}, {"Idempotency-Key": f"{manifest['manifestId']}-r{manifest['revision']}"})
batch_id = batch["batchId"]
after = 0
while True:
state = call_json("GET", f"/batches/{batch_id}/wait?after={after}&timeout=30")
after = state["nextAfter"]
if state["status"] not in RUNNING:
break
print(state["status"], state["counts"])
if state["resultReady"]:
result = call_json("GET", f"/batches/{batch_id}/result.json")
if "zip" not in result:
raise SystemExit("This batch has no ZIP. Read result.json for the failures.")
archive = call("GET", f"/batches/{batch_id}/result.zip")
if hashlib.sha256(archive).hexdigest() != result["zip"]["sha256"]:
raise SystemExit("The ZIP does not match the hash in result.json. Do not install it.")
with open("forge-asset-pack.zip", "wb") as target:
target.write(archive)The command line client
myrlin_forge.py is a single Python 3 file that uses only the standard library. Pass --base-url every time; it has no default service. It reads the key from MYRLIN_FORGE_API_KEY or from a file given with --key-file. Every command prints one JSON summary.
export MYRLIN_FORGE_API_KEY="..." # or pass --key-file PATH
python myrlin_forge.py --base-url https://api.myrlin.io pricing
python myrlin_forge.py --base-url https://api.myrlin.io lint manifest.json --inputs inputs
python myrlin_forge.py --base-url https://api.myrlin.io submit manifest.json --inputs inputs --max-credits 0 --wait --staging review-stage
# Resume a wait that timed out (exit code 7):
python myrlin_forge.py --base-url https://api.myrlin.io wait BATCH_ID --state manifest.json.forge-state.json --staging review-stage
python myrlin_forge.py --base-url https://api.myrlin.io receipt BATCH_ID
python myrlin_forge.py --base-url https://api.myrlin.io review review.json --project example_pack --evidence evidencesubmitcreates or updates the manifest's project, uploads inputs, runs lint, gets a quote and checks it against--max-creditsbefore it approves.- Before it submits, the client saves the exact approval, quote, input fingerprint and idempotency key to
manifest.json.forge-state.json(or the path in--state). A lost response can then be replayed without a new quote or a duplicate batch. The state file holds no API key. --waitlong polls and keeps its cursor. A timeout is resumable withwait.- With
--staging DIRthe client verifies the manifest identity, asset ids, paths, the ZIP hash and every file hash, then writes the files to DIR. It refuses links, unexpected files and conflicting existing bytes, and never merges into a mod tree. A partial batch still stages its verified files before it returns exit code 5. review --evidence DIRuploads the screenshots and logs the review references and binds their paths and hashes to the uploaded set.
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | Failure in a legacy command (quote, run, status, download). |
| 2 | Invalid input or lint failure. |
| 3 | Remote failure. |
| 4 | Approval or credit ceiling refusal. |
| 5 | Partial or failed batch. |
| 6 | Integrity failure: a hash or identity did not match. |
| 7 | Timeout that can be resumed. |
Human review still decides visual quality and whether a file works in the game. See Reviews and free fixes.