Guide · AWS IoT · IoT Jobs · Firmware OTA · Execution Tracking · Fleet Management

AWS IoT Jobs for MCP Servers — Remote Device Management, Firmware Delivery, and Execution Tracking

AWS IoT Jobs is the managed service for deploying operations to IoT device fleets — firmware updates, configuration changes, remote diagnostics, certificate rotations. Each job is a document dispatched to one or more Things or Thing Groups, with tracked execution state per device. MCP server tools use Jobs in two roles: as the job creator (the management plane MCP tool that creates, monitors, and cancels jobs) and as the execution monitor (querying per-device execution status to surface rollout progress). This guide covers creating jobs, the execution state machine, presigned URL delivery for large firmware binaries, snapshot vs continuous rollout, rate configuration, abort criteria, timeout, and the full lifecycle management API.

TL;DR

An IoT Job has: a jobId, a set of targets (Thing ARNs or Thing Group ARNs), a job document (inline JSON) or documentSource (S3 URI), and optional rollout and timeout configuration. Per-device execution tracks through states: QUEUED → IN_PROGRESS → SUCCEEDED / FAILED / REJECTED / TIMED_OUT / REMOVED / CANCELED. Use presignedUrlConfig to let devices download large firmware binaries from S3 without embedding URLs in the job document. Set targetSelection: CONTINUOUS for rolling deployments to growing Thing Groups; use SNAPSHOT (default) for a fixed set of devices. DeleteJobCommand requires force: true if any execution is not in a terminal state.

Creating a job

import { IoTClient, CreateJobCommand } from '@aws-sdk/client-iot';

const iot = new IoTClient({ region: 'us-east-1' });

// Simple job with inline JSON document
const { jobId, jobArn } = await iot.send(new CreateJobCommand({
  // jobId must be unique within account+region; alphanumeric + hyphen + underscore
  jobId: `firmware-v2-2-0-${Date.now()}`,

  // Targets: Thing ARNs for specific devices, ThingGroup ARNs for groups
  // Mix is allowed — target individual Things AND Thing Groups
  targets: [
    'arn:aws:iot:us-east-1:123456789012:thinggroup/floor-3-sensors',
    'arn:aws:iot:us-east-1:123456789012:thing/sensor-floor-3-rack-7',  // specific override
  ],

  // Inline document — arbitrary JSON the device receives and interprets
  document: JSON.stringify({
    operation: 'firmware-update',
    firmwareVersion: '2.2.0',
    // S3 key reference — device fetches using presigned URL (see presignedUrlConfig below)
    firmwareKey: 'firmware/v2.2.0/sensor-firmware.bin',
    checksum: 'sha256:abc123...',
    checksumAlgorithm: 'SHA256',
    // Device-specific instructions
    rebootRequired: true,
    maintenanceWindowSeconds: 300,
  }),

  // OR use documentSource to reference a pre-uploaded document in S3:
  // documentSource: 's3://my-iot-jobs/firmware-v2.2.0-job.json',

  // targetSelection: SNAPSHOT (default) — targets are fixed at creation time
  // targetSelection: CONTINUOUS — also applies to Things added to target groups after creation
  targetSelection: 'SNAPSHOT',

  description: 'Firmware update to v2.2.0 for floor-3 temperature sensors',
}));

console.log('Created job:', jobId, jobArn);
// jobArn: arn:aws:iot:us-east-1:123456789012:job/firmware-v2-2-0-1731147600000

jobId uniqueness: jobId must be unique within an account and region. Once used, a jobId cannot be reused even after the job is deleted (jobs are soft-deleted; the ID reservation persists). Use a timestamp or UUID suffix to ensure uniqueness across repeated deployments of the same firmware version.

Inline document vs documentSource: use inline document for small documents (configuration changes, feature flags, simple commands). Use documentSource (S3 URI) for documents larger than 32 KB or for documents that are reused across multiple jobs with the same parameters. The S3 document is fetched by IoT Core at job creation time and cached — changes to the S3 file after job creation do not affect the job.

Presigned URL delivery for firmware binaries

Firmware binaries can be hundreds of megabytes — too large to embed in a job document and inappropriate to serve with long-lived S3 URLs in the document body. IoT Jobs handles this with presigned URL generation: include an S3 reference in the job document and configure a presigned URL role; IoT Core generates a time-limited signed URL that the device requests when it starts the job execution.

import { CreateJobCommand } from '@aws-sdk/client-iot';

await iot.send(new CreateJobCommand({
  jobId: `firmware-v2-2-0-ota-${Date.now()}`,
  targets: ['arn:aws:iot:us-east-1:123456789012:thinggroup/all-sensors'],
  document: JSON.stringify({
    operation: 'ota-update',
    firmwareVersion: '2.2.0',
    // S3 key — referenced without a URL; IoT Core generates a presigned URL on demand
    // The device receives the actual presigned URL in its GetPendingJobExecutions response
    files: [
      {
        fileName: 'sensor-firmware.bin',
        fileSize: 12582912,  // 12 MB
        // This is the S3 reference IoT Core uses to generate the presigned URL:
        fileLocation: {
          s3Location: {
            bucket: 'my-iot-firmware',
            key: 'firmware/v2.2.0/sensor-firmware.bin',
            version: 'YabcX3...',  // S3 object version ID for immutability
          }
        },
        checksum: {
          algorithm: 'SHA256',
          value: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
        }
      }
    ],
    rebootRequired: true,
  }),

  // presignedUrlConfig — enables IoT Core to generate presigned S3 URLs for files
  presignedUrlConfig: {
    // IAM role that IoT Core assumes to call s3:GetObject on your firmware bucket
    roleArn: 'arn:aws:iam::123456789012:role/IoTJobsPresignedUrlRole',
    // How long the presigned URL is valid after the device requests it (60-7200 seconds)
    expiresInSec: 3600,  // 1 hour — device must download within this window
  },
}));

Presigned URL IAM role: the role needs s3:GetObject on the firmware bucket, with a trust policy allowing iot.amazonaws.com to assume it. The presigned URL is generated fresh each time the device calls GetPendingJobExecutions or requests it via the MQTT topic $aws/things/{thingName}/jobs/{jobId}/get. If the device does not download within the expiresInSec window, it must re-request the presigned URL — the job execution does not fail automatically.

S3 object versioning: always include the S3 object version ID in the fileLocation. Without it, if the firmware binary in S3 is replaced, devices that download the firmware after the replacement get a different binary than devices that downloaded before — a critical safety hazard. With a version ID pinned in the job document, every device gets the exact binary that existed when the job was created.

Job execution state machine

IoT Core tracks one execution record per Thing targeted by the job. Each execution moves through a state machine; terminal states cannot transition further.

State Terminal? Who sets it Meaning
QUEUED No IoT Core Job created; device has not yet acknowledged it
IN_PROGRESS No Device (via UpdateJobExecutionCommand) Device acknowledged the job and is executing; in-progress timeout starts here
SUCCEEDED Yes Device Device reported successful completion
FAILED Yes Device Device reported a failure; detail in statusDetails
REJECTED Yes Device Device explicitly rejected the job (e.g., firmware incompatible with hardware variant)
TIMED_OUT Yes IoT Core Device did not complete within inProgressTimeoutInMinutes after moving to IN_PROGRESS
REMOVED Yes IoT Core Job was deleted while this execution was not yet terminal
CANCELED Yes MCP server via CancelJobExecutionCommand or CancelJobCommand Job execution canceled by the management plane before completion

The device transitions its own execution by calling UpdateJobExecutionCommand as it progresses. IoT Core never automatically transitions a device from QUEUED to IN_PROGRESS — the device must explicitly claim the job. IoT Core only transitions autonomously into TIMED_OUT (via the timeout config) and REMOVED (via job deletion).

Device-side job handling (SDK reference)

Device firmware interacts with IoT Jobs via dedicated MQTT topics (the Jobs data plane). The same operations are available via the Jobs HTTP Data Plane API for server-side tooling that simulates device behavior in tests.

import {
  IoTJobsDataPlane,
  GetPendingJobExecutionsCommand,
  StartNextPendingJobExecutionCommand,
  UpdateJobExecutionCommand,
  DescribeJobExecutionCommand,
} from '@aws-sdk/client-iot-jobs-data-plane';

const jobsData = new IoTJobsDataPlane({
  region: 'us-east-1',
  endpoint: `https://${process.env.IOT_ENDPOINT}`,
});

const thingName = 'sensor-floor-3-rack-7';

// 1. Poll for queued jobs on reconnect (or use MQTT topics for push notification)
const { queuedJobs, inProgressJobs } = await jobsData.send(
  new GetPendingJobExecutionsCommand({ thingName })
);

// 2. Start the next queued job — transitions QUEUED → IN_PROGRESS atomically
const { execution } = await jobsData.send(
  new StartNextPendingJobExecutionCommand({
    thingName,
    // statusDetails: custom key-value metadata stored with the execution
    statusDetails: { startedAt: new Date().toISOString(), agentVersion: '2.1.0' },
  })
);

if (!execution) {
  console.log('No pending jobs');
} else {
  const { jobId, jobDocument } = execution;
  const doc = JSON.parse(jobDocument ?? '{}');

  // 3. Report interim progress during long-running operations
  await jobsData.send(new UpdateJobExecutionCommand({
    thingName,
    jobId: jobId!,
    status: 'IN_PROGRESS',
    statusDetails: { progress: '35%', step: 'downloading-firmware' },
    // stepTimeoutInMinutes: extend the in-progress timeout for this step
    // (overrides job-level timeout for this update call only)
    stepTimeoutInMinutes: 30,
  }));

  // 4. Report final outcome
  const updateSucceeded = performFirmwareUpdate(doc);
  await jobsData.send(new UpdateJobExecutionCommand({
    thingName,
    jobId: jobId!,
    status: updateSucceeded ? 'SUCCEEDED' : 'FAILED',
    statusDetails: updateSucceeded
      ? { completedAt: new Date().toISOString(), newFirmwareVersion: doc.firmwareVersion }
      : { errorCode: 'CHECKSUM_MISMATCH', errorMessage: 'Downloaded binary checksum did not match' },
  }));
}

function performFirmwareUpdate(doc: any): boolean {
  // actual firmware update logic here
  return true;
}

MQTT push vs HTTP polling: devices can either poll GetPendingJobExecutions on reconnect or subscribe to the MQTT topic $aws/things/{thingName}/jobs/notify (or the more detailed /jobs/notify-next) to receive push notifications when a new job is queued. The push model is preferred for battery-limited devices — no need to wake up and poll, just react to the MQTT message.

stepTimeoutInMinutes: UpdateJobExecutionCommand accepts an optional stepTimeoutInMinutes that extends the in-progress timer for that reporting interval. This is useful when firmware download duration is variable depending on connection quality — report progress updates periodically to keep resetting the timeout, rather than setting a single large timeout that would delay failure detection if the device crashes mid-download.

Monitoring job progress from an MCP server

import {
  IoTClient,
  DescribeJobCommand,
  ListJobExecutionsForJobCommand,
  CancelJobExecutionCommand,
  CancelJobCommand,
  DeleteJobCommand,
} from '@aws-sdk/client-iot';

// Get overall job progress summary
const { job } = await iot.send(new DescribeJobCommand({ jobId: 'firmware-v2-2-0-1731147600000' }));
const { jobProcessDetails } = job!;
console.log({
  queued: jobProcessDetails?.numberOfQueuedThings,
  inProgress: jobProcessDetails?.numberOfInProgressThings,
  succeeded: jobProcessDetails?.numberOfSucceededThings,
  failed: jobProcessDetails?.numberOfFailedThings,
  timedOut: jobProcessDetails?.numberOfTimedOutThings,
  canceled: jobProcessDetails?.numberOfCanceledThings,
  rejected: jobProcessDetails?.numberOfRejectedThings,
  status: job?.status,  // IN_PROGRESS | COMPLETED | CANCELED | DELETION_IN_PROGRESS
});

// List per-device execution status (paginated)
async function getFailedDevices(jobId: string) {
  const failed: Array<{ thingArn: string; statusDetails?: Record }> = [];
  let nextToken: string | undefined;

  do {
    const { executionSummaries, nextToken: next } = await iot.send(
      new ListJobExecutionsForJobCommand({
        jobId,
        status: 'FAILED',  // filter by execution state
        maxResults: 250,
        nextToken,
      })
    );

    for (const summary of executionSummaries ?? []) {
      failed.push({
        thingArn: summary.thingArn!,
        statusDetails: summary.jobExecutionSummary?.statusDetails,
      });
    }
    nextToken = next;
  } while (nextToken);

  return failed;
}

// Cancel a single device's execution (device stays in fleet for future jobs)
await iot.send(new CancelJobExecutionCommand({
  jobId: 'firmware-v2-2-0-1731147600000',
  thingName: 'sensor-floor-3-rack-7',
  // force: true — cancel even if execution is IN_PROGRESS (device may not receive cancel)
  force: false,
}));

// Cancel the entire job for all devices
await iot.send(new CancelJobCommand({
  jobId: 'firmware-v2-2-0-1731147600000',
  // comment — reason for cancellation, stored with the job
  comment: 'Stopping rollout due to 15% failure rate on floor-3 sensors',
  // reasonCode — custom code for analytics
  reasonCode: 'HIGH_FAILURE_RATE',
}));

// Delete a completed job (cleanup)
// Requires force: true if any execution is not in a terminal state
await iot.send(new DeleteJobCommand({
  jobId: 'old-firmware-v2-1-0-1700000000000',
  force: false,  // safe delete — fails if any non-terminal executions remain
}));

Rollout configuration, abort criteria, and timeouts

For large fleets, rolling out a job to all devices simultaneously creates risk — if the firmware has a bug, all devices fail at once. IoT Jobs provides rollout rate control, automatic abort on failure rate thresholds, and in-progress timeouts.

await iot.send(new CreateJobCommand({
  jobId: `firmware-v2-2-0-safe-${Date.now()}`,
  targets: ['arn:aws:iot:us-east-1:123456789012:thinggroup/all-sensors'],
  document: JSON.stringify({ operation: 'firmware-update', firmwareVersion: '2.2.0' }),

  // Rollout configuration — rate at which jobs are dispatched to devices
  jobExecutionsRolloutConfig: {
    // Option 1: fixed rate
    maximumPerMinute: 50,  // dispatch to at most 50 devices per minute

    // Option 2: exponential rate (alternative to maximumPerMinute)
    // exponentialRate: {
    //   baseRatePerMinute: 10,       // start by dispatching to 10 devices/minute
    //   incrementFactor: 2,          // double the rate each interval
    //   rateIncreaseCriteria: {
    //     numberOfNotifiedThings: 100,   // double after 100 devices notified
    //     // OR: numberOfSucceededThings: 50 — double after 50 succeed
    //   }
    // }
  },

  // Abort configuration — stop the rollout if failure rate exceeds threshold
  abortConfig: {
    criteriaList: [
      {
        failureType: 'FAILED',          // execution failures
        action: 'CANCEL',               // cancel the entire job
        thresholdPercentage: 10,        // if >10% of devices fail...
        minNumberOfExecutedThings: 100, // ...but only after at least 100 have run
        // The minimum prevents premature cancellation on the first few devices
      },
      {
        failureType: 'TIMED_OUT',
        action: 'CANCEL',
        thresholdPercentage: 20,
        minNumberOfExecutedThings: 50,
      },
    ],
  },

  // Timeout configuration — how long a device has to complete after moving to IN_PROGRESS
  timeoutConfig: {
    inProgressTimeoutInMinutes: 60,  // 1 hour to download + install + reboot
  },
}));

Abort config and minimum threshold: always set minNumberOfExecutedThings to a meaningful sample size (at least 10-20% of your fleet or 100, whichever is smaller). Without it, the first device to fail on a fleet of 10,000 would immediately cancel the job at 100% failure rate — aborting before the rollout has a statistical basis.

Exponential rate configuration: exponential rollout is the recommended strategy for high-stakes updates (firmware, certificate rotation). Start slow, let the first batch of devices validate the update, then accelerate. The numberOfSucceededThings criterion ensures you are increasing rate based on confirmed successful outcomes, not just notifications.

Timeout config vs stepTimeoutInMinutes: inProgressTimeoutInMinutes in the job creation is the total allowed time from IN_PROGRESS to any terminal state. stepTimeoutInMinutes in UpdateJobExecutionCommand resets the timer per step, allowing devices to extend their time for long-running steps like large file downloads. If the device stops reporting progress, the step timeout fires and transitions the execution to TIMED_OUT.

Snapshot vs continuous deployment

Mode targetSelection How targets are resolved Best for
Snapshot SNAPSHOT (default) IoT Core resolves the target list at job creation time; devices added to target groups after creation are not included Point-in-time fleet updates; known set of devices; rollback planning
Continuous CONTINUOUS IoT Core re-evaluates targets dynamically; new devices added to a target Thing Group automatically receive the job Ensuring all devices in a group always have the latest firmware; zero-touch provisioning where new devices auto-receive baseline configuration

Continuous job lifecycle: a continuous job remains IN_PROGRESS (at the job level, not execution level) indefinitely. It applies to new Things added to the target group even months after creation. Cancel it explicitly when you want to stop dispatching — do not rely on the job reaching COMPLETED status (it never will). Use continuous jobs for baseline configuration and certificate installation in device provisioning flows; use snapshot jobs for firmware rollouts where you want a clear before/after fleet state.

Combining continuous and snapshot: common pattern for fleet management from MCP tools — maintain one continuous job per configuration baseline (e.g., MQTT settings, root CA certificate, telemetry interval) and create snapshot jobs for firmware versions. The continuous job handles new device provisioning; snapshot jobs handle fleet-wide updates. MCP tools query both job types in ListJobsCommand (filter by targetSelection) to show fleet health.

Common errors and their causes

Error / symptom Cause Fix
ResourceAlreadyExistsException on CreateJobCommand jobId was already used — job IDs are globally reserved even after deletion Include a timestamp or UUID in the job ID; never reuse job IDs; keep a job ID registry in your MCP server's database
InvalidRequestException: Job document exceeds 32768 bytes Inline document is too large; limit is 32 KB Upload the document to S3 and reference it via documentSource
ConflictException on DeleteJobCommand Job has executions in non-terminal states (QUEUED or IN_PROGRESS) and force is false Either cancel the job first with CancelJobCommand (wait for all executions to reach CANCELED), then delete; or set force: true to delete immediately (transitions non-terminal executions to REMOVED)
Devices stuck in QUEUED indefinitely Device is offline and either (a) the job is snapshot with no timeout for QUEUED state, or (b) device does not poll GetPendingJobExecutions on reconnect IoT Core does not timeout QUEUED executions automatically — only IN_PROGRESS executions are subject to inProgressTimeoutInMinutes; ensure device firmware calls GetPendingJobExecutions or subscribes to /jobs/notify on every reconnect
Abort triggered prematurely on first few devices minNumberOfExecutedThings is too low or unset; first device failure hits the percentage threshold Set minNumberOfExecutedThings to at least 10% of fleet size or 100 (whichever is higher) so the abort criterion only fires after a statistically meaningful sample
Presigned URL expired before device downloads firmware expiresInSec is too short for the device's download time given its connection speed Increase expiresInSec (maximum 7200 seconds); the device can re-request a fresh presigned URL by calling the MQTT get topic or DescribeJobExecutionCommand again — the URL is regenerated on each request