Wiring Cask metadata into Installomator using Patcher's API

Every IT department spends real time deciding what software is allowed on employee machines and what isn’t. The criteria are organization-specific and out of scope for this article. What’s in scope, however, is keeping an app that’s crossed the threshold from “allowed” to “actively deployed” up to date. It’s a dance every MacAdmin knows by heart.

The process usually starts with triaging which automation source supports the application, if any. Is it in Installomator? AutoPkg? Homebrew Cask? Vendor-provided PKG?

When the answer is “none of them”, you’re back to building the package by hand. Different vendors ship installers in different shapes and version updates require redoing the work.

This is where Installomator’s valuesfromarguments feature becomes a real game changer.

Going label-less

Installomator’s underlying functionality already handles installing almost any installer file. valuesfromarguments allows admins to leverage Installomator for “label-less” titles by passing the four required variables the script expects:

  • name — the application name as it appears in Applications, minus the .app extension
  • type — the type of installer file
  • downloadURL — where the installer lives
  • expectedTeamID — the Apple Developer Team ID, for signature verification before installing

Three of those four are still vendor-website grunt work. The fourth, expectedTeamID, requires inspecting a signed binary. Either download the installer and run codesign -dvvv against the app inside, or use a tool like Apparency by Mothers Ruin Software.

I wanted to automate as much of this as possible. That’s where Patcher comes in.

The Patcher API solution

Patcher ships with a public catalog API at api.patcherctl.dev that stitches Installomator, Homebrew Cask, AutoPkg, and Jamf App Installer metadata into one queryable surface. It exposes a generate-label endpoint that does exactly what its name suggests: given an app slug, it returns a label-shaped object you can drop into Installomator’s valuesfromarguments flow. No authentication required.

Walking through ChatGPT Atlas

OpenAI shipped Atlas in October 2025, and at the time of writing it doesn’t have an Installomator label. It does have a Homebrew Cask entry, making it a perfect candidate to use as a demo.

Let’s make the API call to Patcher and see what comes back.

curl -s -X POST "https://api.patcherctl.dev/apps/chatgpt-atlas/generate-label" | jq

The response:

{
    "label_name": "chatgpt-atlas",
    "content": {
        "name": "ChatGPT Atlas",
        "type": "dmg",
        "downloadURL": "https://persistent.oaistatic.com/atlas/public/ChatGPT_Atlas_Desktop_public_1.2026.119.1_20260504231115000.dmg",
        "appNewVersion": "1.2026.119.1,20260504231115000"
    },
    "sources_used": ["homebrew_cask"],
    "warnings": [
        "expectedTeamID unknown — Installomator requires this for code-sign verification. Determine manually via `codesign -dvvv <path-to-app>` before deploying."
    ]
}

We can see from the response that three of the four required fields came back automatically (name, type, downloadURL). The endpoint also surfaced an appNewVersion, which you could use for version-targeting if needed.

The fourth, expectedTeamID, isn’t in the Cask metadata, so the endpoint tells you exactly what to run next. My personal preference is Apparency, but for the sake of demonstration we will download the DMG, mount it, and codesign the app inside.

$ hdiutil attach ~/Downloads/ChatGPT_Atlas_Desktop_public_1.2026.119.1_20260504231115000.dmg
...
/dev/disk4s1    Apple_HFS   /Volumes/ChatGPT Atlas Installer

$ codesign -dvvv "/Volumes/ChatGPT Atlas Installer/ChatGPT Atlas.app" 2>&1 | grep TeamIdentifier
TeamIdentifier=2DC432GLL2
hdiutil: Mount the installer DMG and note the volume name; codesign: Inspect the app’s Team ID

Looking it up programmatically

If you’re deploying via Python, the same lookup is one HTTP request.

"""Resolve the four Installomator valuesfromarguments fields for a given app slug."""
import sys
import requests

def generate_label(slug: str) -> dict:
    """Hit the Patcher API and return the generated label."""
    response = requests.post(f"https://api.patcherctl.dev/apps/{slug}/generate-label")
    response.raise_for_status()
    return response.json()

def main(slug: str) -> None:
    result = generate_label(slug)
    content = result["content"]
    print(f"name={content['name']}")
    print(f"type={content['type']}")
    print(f"downloadURL={content['downloadURL']}")
    for warning in result.get("warnings", []):
        print(f"# {warning}")


if __name__ == "__main__":
    main(sys.argv[1])

Save the script and give it a descriptive name. Execute the script using chatgpt-atlas as our slug name to see the output:

$ python3 generate_label.py chatgpt-atlas
...
name=ChatGPT Atlas
type=dmg
downloadURL=https://persistent.oaistatic.com/atlas/public/ChatGPT_Atlas_Desktop_public_1.2026.119.1_20260504231115000.dmg
# expectedTeamID unknown — Installomator requires this for code-sign verification...

Deploying via Terraform

Assuming you’ve already declared the Installomator script as a jamfpro_script resource, the policy resource references that script by ID and passes the four field values as numbered parameters.

resource "jamfpro_policy" "chatgpt_atlas_install" {
  name          = "Install ChatGPT Atlas"
  enabled       = true
  trigger_other = "USER_INITIATED"
  frequency     = "Ongoing"
  target_drive  = "/"
  category_id   = jamfpro_category.standard_issue.id
  site_id       = -1

  scope {
    all_computers = true
  }

  self_service {
    use_for_self_service      = true
    self_service_display_name = "ChatGPT Atlas"
    install_button_text       = "Install"
    reinstall_button_text     = "Reinstall"
    self_service_description  = "Install or update OpenAI's ChatGPT Atlas browser."

    self_service_category {
      id         = jamfpro_category.standard_issue.id
      display_in = true
      feature_in = false
    }
  }

  payloads {
    scripts {
      id         = jamfpro_script.installomator.id
      priority   = "After"
      parameter4 = "valuesfromarguments"
      parameter5 = "name=ChatGPT Atlas"
      parameter6 = "type=dmg"
      parameter7 = "downloadURL=https://persistent.oaistatic.com/atlas/public/ChatGPT_Atlas_Desktop_public_1.2026.119.1_20260504231115000.dmg"
      parameter8 = "expectedTeamID=4P9YKVZSXJ"
    }
  }
}
terraform/policies.tf (install ChatGPT Atlas via Installomator’s valuesfromarguments)

For ad-hoc deploys, parameter 4 is set to valuesfromarguments instead of the label name. Parameters 5 through 11 carry the field-value pairs Installomator expects.

Deploying via Python and the Classic API

For shops not using Terraform, policies can be created using Jamf’s Classic API. The modern Jamf Pro API hasn’t shipped policy CRUD endpoints yet, so the script below POSTs XML to /JSSResource/policies/id/0 instead.

"""Deploy ChatGPT Atlas via Installomator as a Jamf Pro policy."""
import xml.etree.ElementTree as ET
import requests

JAMF_URL = "https://yourorg.jamfcloud.com"
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"

INSTALLOMATOR_SCRIPT_ID = "123"  # ID of your uploaded Installomator script
CATEGORY_ID = "10"

ATLAS_DOWNLOAD_URL = (
    "https://persistent.oaistatic.com/atlas/public/"
    "ChatGPT_Atlas_Desktop_public_1.2026.119.1_20260504231115000.dmg"
)

def get_bearer_token() -> str:
    """OAuth client_credentials grant against the Jamf Pro API."""
    response = requests.post(
        f"{JAMF_URL}/api/oauth/token",
        data={
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "grant_type": "client_credentials",
        },
    )
    response.raise_for_status()
    return response.json()["access_token"]

def build_policy_xml() -> bytes:
    """Construct the Classic API policy XML payload."""
    policy = ET.Element("policy")

    general = ET.SubElement(policy, "general")
    ET.SubElement(general, "name").text = "Install ChatGPT Atlas"
    ET.SubElement(general, "enabled").text = "true"
    category = ET.SubElement(general, "category")
    ET.SubElement(category, "id").text = CATEGORY_ID
    ET.SubElement(general, "trigger_other").text = "USER_INITIATED"
    ET.SubElement(general, "frequency").text = "Ongoing"

    self_service = ET.SubElement(policy, "self_service")
    ET.SubElement(self_service, "use_for_self_service").text = "true"
    ET.SubElement(self_service, "self_service_display_name").text = "ChatGPT Atlas"
    ET.SubElement(self_service, "install_button_text").text = "Install"
    ET.SubElement(self_service, "reinstall_button_text").text = "Reinstall"

    scripts = ET.SubElement(policy, "scripts")
    ET.SubElement(scripts, "size").text = "1"
    script = ET.SubElement(scripts, "script")
    ET.SubElement(script, "id").text = INSTALLOMATOR_SCRIPT_ID
    ET.SubElement(script, "priority").text = "After"
    ET.SubElement(script, "parameter4").text = "valuesfromarguments"
    ET.SubElement(script, "parameter5").text = "name=ChatGPT Atlas"
    ET.SubElement(script, "parameter6").text = "type=dmg"
    ET.SubElement(script, "parameter7").text = f"downloadURL={ATLAS_DOWNLOAD_URL}"
    ET.SubElement(script, "parameter8").text = "expectedTeamID=2DC432GLL2"

    return ET.tostring(policy, encoding="utf-8", xml_declaration=True)

def create_policy(token: str, xml_body: bytes) -> None:
    """POST the policy XML to the Classic API. ID 0 means 'new'."""
    response = requests.post(
        f"{JAMF_URL}/JSSResource/policies/id/0",
        data=xml_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/xml",
            "Accept": "application/xml",
        },
    )
    response.raise_for_status()
    print(f"Created with status: {response.status_code}")


if __name__ == "__main__":
    token = get_bearer_token()
    create_policy(token, build_policy_xml())

Bonus: lookup from Claude Code

If Claude Code is already part of your workflow, Patcher ships a skill at .claude/skills/patcher/ that answers the “what coverage exists for this app?” question without leaving the chat session:

/patcher chatgpt-atlas

The skill checks Installomator, Homebrew Cask, AutoPkg, and Jamf App Installer in parallel and prints a coverage report per source. It’s a faster way to confirm “yes, Cask has it, Installomator doesn’t” before reaching for the generate-label endpoint.

Project-scoped install is automatic if you clone Patcher: open any Claude Code session from the repo root and the skill loads itself. For a user-scoped install that’s available across every project on your machine, see the skill documentation.

Wrap-up

That’s the dance, condensed:

  1. curl https://api.patcherctl.dev/apps/<slug>/generate-label for the three resolvable fields
  2. codesign -dvvv (or Apparency) against the signed binary for expectedTeamID
  3. terraform apply, or POST the Classic API XML

ChatGPT Atlas was today’s demo, but the same recipe works for any app that has a Cask entry and no Installomator label yet. That’s most of the long tail.

The Patcher API is public and unauthenticated, so the lookup step works from anywhere. Endpoint reference and full schema live in project docs. The OpenAPI schema and Swagger UI are also available if you’d rather poke at the surface directly.

If you find an app where generate-label comes up short, or you want to contribute a resolver for fields beyond what Cask provides, open an issue on the Patcher repo or find me in the #patcher channel on MacAdmins Slack. The catalog gets better the more apps it covers, and the most useful contributions are usually “I tried this against $vendor and here’s what came back garbled.”