Asset pipeline API

Send a manifest that lists every file a mod needs. The API plans the work, prices it, runs it, and returns a ZIP of delivered files with a result document that records a hash and the checks for each file.

What it does and what it does not prove

A batch takes a manifest in the myrlin.asset-manifest/1 format, optional input files such as reference art, and your approval of a quote. It delivers a ZIP plus result.json. Each delivered file passed Forge's format and rule checks. Those checks say nothing about how an asset looks in the game. Review the output yourself before you install it.

All routes live under https://api.myrlin.io/api/pipeline/v1. Errors, limits and prices on these pages are generated from the API code, not typed by hand. They describe API version 0.3.0.

Before you start

  • Any active Forge account can use the pipeline with its credits: read, quote and lint. Starting a batch needs an active plan (Creator, Pro or Max); without one the submit is refused with 403 batch_subscription_required.
  • You need a key that carries pipeline scopes. A key made with only a name has none and gets 403 no_pipeline_grant. See Keys and safekeeping.
  • Send the key as X-API-Key: myrl_... or as Authorization: Bearer myrl_..., never both. Both together is 400 ambiguous_credentials.
  • Keep the key in an environment variable or a secret manager. The samples read MYRLIN_FORGE_API_KEY.

Five steps from a key to a ZIP

The steps use a free manifest: one JSON file that Forge emits from the content you give it. It costs 0 credits. The shell samples need curl and jq. Save this as manifest.json:

manifest.json
{
  "schema": "myrlin.asset-manifest/1",
  "manifestId": "example-pack",
  "revision": 1,
  "project": {
    "name": "Example pack",
    "slug": "example_pack"
  },
  "namespace": "examplemod",
  "targets": [
    "mc-26.1-neoforge"
  ],
  "style": {
    "summary": "Plain test palette",
    "palette": [
      {
        "role": "ink",
        "color": "#000000"
      }
    ]
  },
  "assets": [
    {
      "id": "definitions",
      "name": "Definitions",
      "kind": "other",
      "status": "missing",
      "action": "generate",
      "describe": "Emit the declared JSON.",
      "representations": [
        {
          "id": "definitions",
          "role": "other",
          "files": [
            {
              "path": "assets/examplemod/data/example.json",
              "format": "json",
              "action": "emit",
              "content": {
                "answer": 42
              }
            }
          ]
        }
      ]
    }
  ]
}

The manifest passes the schema check. It has not been run against a live server for this page, so a response on your side can differ in detail; the field names below are the ones the routes return.

1. Set up the key and check it

GET /me needs no scope. It returns the key's scopes, its bound project and its credit limits.

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"

2. Create the project

The manifest's project.slug must match the project in the URL. A slug is 1 to 48 lowercase letters, digits or underscores. This needs projects:write.

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"}'

3. Quote

The quote lists every file, what it will cost and which files cannot be made. It returns planSha256, which identifies exactly this plan, and totalCredits. Nothing is created and nothing is charged. This needs batches: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

4. Submit

Approve the quote by sending planSha256 back with a maxCredits ceiling and an Idempotency-Key header of up to 64 printable ASCII characters. If the plan changed since the quote the call is refused with 409 quote_changed. Repeating the same call with the same key returns the same batch. This needs batches: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"

5. Wait and download

/wait holds the request for up to 30 seconds and returns as soon as the batch has a new event or has ended. When the status is no longer queued, running, packaging or canceling, the batch is done. Download needs results:read.

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

The same flow in Python

One file, standard library only. It reads the manifest from manifest.json, stops with the API's error code on any failed call, and checks the ZIP against the hash in result.json before it writes it.

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)

Limits to know before a first real batch

Headline limits
LimitValue
One manifestup to 8 MB, 25,000 files and 4,000 compute units
One input ZIPup to 32 MB
One result ZIPup to 128 MB
A batch expires14 days after it is created

Rate limits, plan limits and credit caps are on Plans and limits. Every route is listed in the reference.