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

shell
# 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

shell
# 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.

shell
# 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.

shell
# 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

shell
# 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.json

Submit

shell
# 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

shell
# 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

shell
# 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.zip

Status, events, receipt, link, cancel

shell
# 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.

shell
# 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.json

Python, 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.

python
"""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.

shell
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 evidence
  • submit creates or updates the manifest's project, uploads inputs, runs lint, gets a quote and checks it against --max-credits before 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.
  • --wait long polls and keeps its cursor. A timeout is resumable with wait.
  • With --staging DIR the 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 DIR uploads the screenshots and logs the review references and binds their paths and hashes to the uploaded set.
Exit codes of myrlin_forge.py
CodeMeaning
0Success.
1Failure in a legacy command (quote, run, status, download).
2Invalid input or lint failure.
3Remote failure.
4Approval or credit ceiling refusal.
5Partial or failed batch.
6Integrity failure: a hash or identity did not match.
7Timeout that can be resumed.

Human review still decides visual quality and whether a file works in the game. See Reviews and free fixes.