Pipeline template

Forge → AWS IoT SiteWise

When any machine normalizes, push its canonical readings straight into SiteWise. Forge handles the tag translation; this template handles the transport.

Setup: ~5 minutes Forge plan: any plan with automations Trigger type: simple on normalize Requires: a small AWS shim (see step 1)

Before and after

Manual

  1. Pull tags off the controller with a vendor-specific driver.
  2. Hand-write a tag map: Sload → spindle load, per OEM, per model.
  3. Re-map it again for the next machine that spells it differently.
  4. Build an ingestion Lambda that knows every tag name you invented.
  5. Create SiteWise assets and wire each property by ID.
  6. Maintain the map forever as firmware and models change.

With Forge

  1. Point your equipment at /v1/normalize.
  2. Forge returns canonical field names, whatever the OEM called them.
  3. Register SiteWise once as a Forge tool.
  4. Create one trigger. It fires on every normalize event.
  5. SiteWise property aliases match the canonical names directly.
  6. New machine, new OEM, new tag spelling: no new mapping work.

How the data gets there

Forge tools authenticate with none, bearer, basic, or hmac. AWS data-plane APIs require SigV4 request signing, which is not a static header, so Forge cannot call BatchPutAssetPropertyValue directly. The standard pattern, and the one below, is a thin API Gateway + Lambda shim that signs the call for you.

Machine tags          Forge kernel              Your AWS account
─────────────         ─────────────────         ────────────────────────
Sload: 72.5     ──►   /v1/normalize
CoolantTemp: 85 ──►   canonical fields    ──►   API Gateway (bearer token)
PartCount: 143  ──►   trigger fires             └─► Lambda (signs SigV4)
                                                    └─► SiteWise BatchPutAssetPropertyValue
Why the shim earns its keep. It is not just a signature. It also converts Forge's ISO-8601 fired_at into SiteWise's epoch timeInSeconds, and casts numeric readings to JSON numbers. Both are required by the SiteWise API and neither is something a webhook body template can express.

Canonical field → SiteWise property

Forge's canonical names are stable across OEMs, so they make good SiteWise property aliases. Create the alias once per property and you never look up a propertyId again. These are real canonical names emitted by /v1/normalize.

Forge canonical fieldSiteWise property aliasValue typeUnit
spindle_load_pct/forge/{machine_id}/spindle_load_pctdoubleValue%
spindle_speed_rpm/forge/{machine_id}/spindle_speed_rpmdoubleValueRPM
sensor_readings.coolant_temp/forge/{machine_id}/coolant_tempdoubleValue°C
sensor_readings.vibration/forge/{machine_id}/vibrationdoubleValuemm/s
sensor_readings.bearing_temp/forge/{machine_id}/bearing_tempdoubleValue°C
parts_count/forge/{machine_id}/parts_countintegerValuecount
program_execution_state/forge/{machine_id}/execution_statestringValueenum
alarm_code/forge/{machine_id}/alarm_codestringValueenum
operating_hours/forge/{machine_id}/operating_hoursdoubleValueh
energy_kwh/forge/{machine_id}/energy_kwhdoubleValuekWh
axes.x_load_pct/forge/{machine_id}/axes/x_load_pctdoubleValue%
tool_life_pct/forge/{machine_id}/tool_life_pctdoubleValue%
Dotted canonical names like sensor_readings.coolant_temp arrive as flat keys, so {{sensor_readings.coolant_temp}} resolves correctly in a payload template. The dot is part of the name, not a path traversal.

Set it up

  1. Deploy the shim

    One Lambda behind an API Gateway HTTP API. It takes Forge's canonical payload and writes it to SiteWise. Paste this as the function body (Python 3.12, needs iotsitewise:BatchPutAssetPropertyValue in its execution role).

    import boto3, json, os, time, uuid
    from datetime import datetime, timezone
    
    sw = boto3.client("iotsitewise")
    SHARED_TOKEN = os.environ["FORGE_SHARED_TOKEN"]
    ALIAS_PREFIX = os.environ.get("ALIAS_PREFIX", "/forge")
    
    # canonical field -> (alias suffix, SiteWise value key)
    MAP = {
        "spindle_load_pct":             ("spindle_load_pct",  "doubleValue"),
        "spindle_speed_rpm":            ("spindle_speed_rpm", "doubleValue"),
        "sensor_readings.coolant_temp": ("coolant_temp",      "doubleValue"),
        "sensor_readings.vibration":    ("vibration",         "doubleValue"),
        "sensor_readings.bearing_temp": ("bearing_temp",      "doubleValue"),
        "parts_count":                  ("parts_count",       "integerValue"),
        "program_execution_state":      ("execution_state",   "stringValue"),
        "alarm_code":                   ("alarm_code",        "stringValue"),
        "operating_hours":              ("operating_hours",   "doubleValue"),
        "energy_kwh":                   ("energy_kwh",        "doubleValue"),
        "tool_life_pct":                ("tool_life_pct",     "doubleValue"),
    }
    
    def _epoch(iso):
        if not iso:
            return int(time.time())
        try:
            return int(datetime.fromisoformat(str(iso).replace("Z", "+00:00")).timestamp())
        except ValueError:
            return int(time.time())
    
    def _cast(kind, raw):
        # Forge interpolates template values as strings; SiteWise needs real types.
        if kind == "doubleValue":
            return {"doubleValue": float(raw)}
        if kind == "integerValue":
            return {"integerValue": int(float(raw))}
        return {"stringValue": str(raw)}
    
    def handler(event, _ctx):
        if event.get("headers", {}).get("authorization") != f"Bearer {SHARED_TOKEN}":
            return {"statusCode": 401, "body": "unauthorized"}
    
        body       = json.loads(event.get("body") or "{}")
        machine_id = body.get("machine_id") or "unknown"
        ts         = _epoch(body.get("fired_at"))
        readings   = body.get("readings") or {}
    
        entries = []
        for field, raw in readings.items():
            if field not in MAP or raw is None or str(raw).startswith("{{"):
                continue                      # unmapped, or the field wasn't in this event
            suffix, kind = MAP[field]
            try:
                value = _cast(kind, raw)
            except (TypeError, ValueError):
                continue                      # non-numeric reading on a numeric property
            entries.append({
                "entryId": uuid.uuid4().hex[:32],
                "propertyAlias": f"{ALIAS_PREFIX}/{machine_id}/{suffix}",
                "propertyValues": [{
                    "value": value,
                    "timestamp": {"timeInSeconds": ts, "offsetInNanos": 0},
                    "quality": "GOOD",
                }],
            })
    
        if not entries:
            return {"statusCode": 204, "body": ""}
    
        written, errors = 0, []
        for i in range(0, len(entries), 10):          # SiteWise caps at 10 entries per call
            resp = sw.batch_put_asset_property_value(entries=entries[i:i + 10])
            errors.extend(resp.get("errorEntries") or [])
            written += len(entries[i:i + 10])
    
        return {"statusCode": 200,
                "body": json.dumps({"written": written - len(errors), "errors": errors})}
    Create the property aliases in SiteWise first (asset model property → Alias), matching the table above. Set FORGE_SHARED_TOKEN to a random secret; you will hand the same value to Forge in step 2.
  2. Register SiteWise as a Forge tool

    The tool holds the destination, the auth, and the body shape. auth_secret is encrypted at rest and never readable back.

    curl -sS -X POST https://forge.foundrynet.io/v1/tools \
      -H "Authorization: Bearer $FORGE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "AWS IoT SiteWise",
        "description": "Push canonical readings to SiteWise via the ingest shim",
        "url": "https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ingest",
        "method": "POST",
        "auth_type": "bearer",
        "auth_secret": "YOUR_FORGE_SHARED_TOKEN",
        "payload_template": {
          "machine_id": "{{machine_id}}",
          "fired_at": "{{timestamp}}",
          "readings": {
            "spindle_load_pct": "{{spindle_load_pct}}",
            "spindle_speed_rpm": "{{spindle_speed_rpm}}",
            "sensor_readings.coolant_temp": "{{sensor_readings.coolant_temp}}",
            "sensor_readings.vibration": "{{sensor_readings.vibration}}",
            "parts_count": "{{parts_count}}",
            "program_execution_state": "{{program_execution_state}}",
            "operating_hours": "{{operating_hours}}",
            "energy_kwh": "{{energy_kwh}}"
          }
        }
      }'

    The response carries the id. Keep it for the next step.

    Any {{field}} the machine did not report in that event passes through as the literal string {{field}}. That is deliberate, so a missing field is visible in delivery logs rather than silently blank. The shim above skips those entries.
  3. Create the automation

    In plain terms: on every normalize event for this machine, POST the canonical readings to SiteWise. A trigger needs a condition, so use one that is always true during production, such as the spindle turning at all.

    curl -sS -X POST https://forge.foundrynet.io/v1/triggers \
      -H "Authorization: Bearer $FORGE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Stream canonical data to SiteWise",
        "machine_id": "YOUR_MACHINE_ID",
        "condition": { "field": "spindle_speed_rpm", "op": "gte", "value": 0 },
        "actions": [ { "tool_id": "TOOL_ID_FROM_STEP_2" } ],
        "enabled": true
      }'
    Every normalize event fires this trigger. At a one-second cadence that is 86,400 SiteWise writes per machine per day. Narrow the condition (for example spindle_speed_rpm > 0 to skip idle time) or throttle upstream if you are cost-sensitive.
  4. Verify

    Send one normalize call with raw OEM tag names and confirm it lands.

    curl -sS -X POST https://forge.foundrynet.io/v1/normalize \
      -H "Content-Type: application/json" \
      -d '{"oem":"Haas","model":"VF-2","machine_id":"YOUR_MACHINE_ID",
           "data":{"Sload":72.5,"Sspeed":8200,"CoolantTemp":85.2,"PartCount":143}}'

    Forge returns the canonical translation:

    {"normalized":{"spindle_load_pct":72.5,
                   "spindle_speed_rpm":8200,
                   "sensor_readings.coolant_temp":85.2,
                   "parts_count":143}, ...}

    Then check delivery, which records status code and response for every dispatch:

    curl -sS https://forge.foundrynet.io/v1/triggers/TRIGGER_ID/executions \
      -H "Authorization: Bearer $FORGE_API_KEY"

What Forge sends the shim

The exact body produced by the template in step 2, after interpolation.

POST /ingest
Authorization: Bearer YOUR_FORGE_SHARED_TOKEN
Content-Type: application/json
X-FoundryNet-Trigger-Id: 4f2c...
X-FoundryNet-Tool-Id: 9a71...

{
  "machine_id": "mint_7f3a91c4",
  "fired_at": "2026-07-28T18:04:11.882714+00:00",
  "readings": {
    "spindle_load_pct": "72.5",
    "spindle_speed_rpm": "8200",
    "sensor_readings.coolant_temp": "85.2",
    "parts_count": "143",
    "program_execution_state": "ACTIVE",
    "operating_hours": "{{operating_hours}}",
    "energy_kwh": "{{energy_kwh}}"
  }
}

And what the shim writes to SiteWise:

{
  "entries": [{
    "entryId": "8c1d4e0f7b2a4a5c9e3f1d6b8a0c2e4f",
    "propertyAlias": "/forge/mint_7f3a91c4/spindle_load_pct",
    "propertyValues": [{
      "value": { "doubleValue": 72.5 },
      "timestamp": { "timeInSeconds": 1785261851, "offsetInNanos": 0 },
      "quality": "GOOD"
    }]
  }]
}

Next