Guide · Amazon Translate · Batch Jobs

MCP Server AWS Translate Batch Jobs — StartTextTranslationJobCommand, polling

Amazon Translate async batch jobs handle large-scale translation workloads — entire S3 prefixes of documents translated to multiple target languages in a single job submission. The entry point is StartTextTranslationJobCommand: you point it at an S3 input prefix containing your source documents and an S3 output prefix for results, then poll DescribeTextTranslationJobCommand until JobStatus reaches COMPLETED or FAILED. Compared to TranslateDocumentCommand (synchronous, 100KB limit, no S3 needed), batch jobs are asynchronous, support multi-MB files, support translating to multiple target languages simultaneously, and cost less per character at scale. The critical requirement is an IAM data access role: Translate needs a service-linked role with s3:GetObject on the input bucket and s3:PutObject on the output bucket — without it, the job fails immediately with InputDataConfig.S3Uri permissions error.

TL;DR

Upload source files to S3. Call StartTextTranslationJobCommand({ JobName, InputDataConfig: { S3Uri, ContentType }, OutputDataConfig: { S3Uri }, DataAccessRoleArn, SourceLanguageCode, TargetLanguageCodes }). Poll DescribeTextTranslationJobCommand({ JobId }) every 30–60 seconds until JobDetails.JobStatus is COMPLETED. Download translated files from the output S3 prefix (Translate creates a subfolder named by job ID and date). The IAM data access role must trust translate.amazonaws.com and have S3 read on input, S3 write on output.

Job architecture overview

Batch translation follows an S3-in / S3-out pattern. Source documents sit in an S3 prefix; Translate reads them, translates each one, and writes the translated output to a separate prefix. For multi-target-language jobs, Translate creates one subdirectory per target language in the output prefix.

# Input structure
s3://my-bucket/translate-input/
  report-q3.txt
  product-spec.html
  user-guide.docx

# Output structure after job completes
s3://my-bucket/translate-output/
  <JobId>-<timestamp>/
    es/                    # Spanish translations
      report-q3.txt
      product-spec.html
      user-guide.docx
    fr/                    # French translations
      report-q3.txt
      product-spec.html
      user-guide.docx
    details/               # Job execution report
      TranslationDetails.json

The output subdirectory name is {JobId}-{ISO8601timestamp} — you do not know it ahead of time. Use the JobDetails.OutputDataConfig.S3Uri from DescribeTextTranslationJobCommand after the job completes to get the exact output prefix to list or download.

IAM data access role setup

Translate needs a dedicated IAM role to access your S3 buckets. This role is passed as DataAccessRoleArn when submitting the job. The role must trust translate.amazonaws.com as a service principal.

# Trust policy — allows Amazon Translate to assume this role
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "translate.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}

# Permission policy — S3 access for input (read) and output (write)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TranslateS3Read",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/translate-input/*"
      ]
    },
    {
      "Sid": "TranslateS3Write",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::my-bucket/translate-output/*"
    }
  ]
}

The s3:ListBucket permission is required (not just s3:GetObject) — Translate lists the input prefix to enumerate files before reading them. Missing s3:ListBucket causes the job to fail with an opaque S3 access error. Scope it to the specific bucket (arn:aws:s3:::my-bucket without a trailing /*) — that is correct for s3:ListBucket.

Submitting a batch translation job

StartTextTranslationJobCommand returns a JobId immediately — the actual translation runs asynchronously. The ContentType in InputDataConfig must match the format of all files in the input prefix; a mix of HTML and plain text requires separate jobs.

import {
  TranslateClient,
  StartTextTranslationJobCommand,
  type StartTextTranslationJobCommandInput,
} from '@aws-sdk/client-translate';

const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

export async function startTranslationJob(opts: {
  jobName: string;
  inputS3Uri: string;        // s3://my-bucket/translate-input/ — prefix, not individual file
  outputS3Uri: string;       // s3://my-bucket/translate-output/
  dataAccessRoleArn: string; // ARN of the IAM role that trusts translate.amazonaws.com
  sourceLanguage: string;    // 'auto' or specific BCP 47 code
  targetLanguages: string[]; // ['es', 'fr', 'de'] — translate to all simultaneously
  terminologyNames?: string[]; // optional custom terminologies
}): Promise<string> {        // returns JobId
  const input: StartTextTranslationJobCommandInput = {
    JobName: opts.jobName,
    InputDataConfig: {
      S3Uri: opts.inputS3Uri,
      ContentType: 'text/plain',  // 'text/plain' | 'text/html' | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    },
    OutputDataConfig: {
      S3Uri: opts.outputS3Uri,
      // EncryptionKey: { Type: 'KMS', Id: 'arn:aws:kms:...' },  // optional KMS encryption
    },
    DataAccessRoleArn: opts.dataAccessRoleArn,
    SourceLanguageCode: opts.sourceLanguage,
    TargetLanguageCodes: opts.targetLanguages,  // array — translates to all in one job
    TerminologyNames: opts.terminologyNames,
    // ParallelDataNames: ['my-parallel-data'],  // optional — for Active Custom Translation
    Settings: {
      Formality: 'NONE',    // 'FORMAL' | 'INFORMAL' | 'NONE'
      Profanity: 'MASK',
    },
  };

  const response = await translate.send(new StartTextTranslationJobCommand(input));

  if (!response.JobId) {
    throw new Error('StartTextTranslationJobCommand did not return a JobId');
  }

  console.log('Translation job submitted:', { jobId: response.JobId, status: response.JobStatus });
  return response.JobId;
}

Specifying multiple TargetLanguageCodes is more efficient than submitting one job per language — Translate charges the same number of characters regardless of how many target languages you include, and the job runs faster than sequential single-language jobs. Use multi-target when you need the same content in several languages.

Polling for job completion

Batch jobs typically complete in a few minutes for small input sets and up to tens of minutes for large corpora. Poll DescribeTextTranslationJobCommand at a reasonable interval — 30–60 seconds is appropriate. Do not poll faster than every 10 seconds.

import {
  TranslateClient,
  DescribeTextTranslationJobCommand,
} from '@aws-sdk/client-translate';

const TERMINAL_STATUSES = new Set([
  'COMPLETED',
  'COMPLETED_WITH_ERROR',
  'FAILED',
  'STOP_REQUESTED',
  'STOPPED',
]);

export async function waitForTranslationJob(
  jobId: string,
  pollIntervalMs = 30_000,
  timeoutMs = 30 * 60 * 1000, // 30-minute timeout
): Promise<{
  status: string;
  outputS3Uri: string | undefined;
  documentsSucceeded: number;
  documentsFailed: number;
  documentsWithError: number;
}> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const response = await translate.send(
      new DescribeTextTranslationJobCommand({ JobId: jobId })
    );

    const job = response.TextTranslationJobProperties;
    if (!job) throw new Error(`Job ${jobId} not found`);

    const status = job.JobStatus ?? 'UNKNOWN';
    console.log(`Job ${jobId} status: ${status}`, {
      submitted: job.SubmittedTime,
      end: job.EndTime,
      docs: job.JobDetails,
    });

    if (TERMINAL_STATUSES.has(status)) {
      return {
        status,
        outputS3Uri: job.OutputDataConfig?.S3Uri,
        documentsSucceeded: job.JobDetails?.TranslatedDocumentsCount ?? 0,
        documentsFailed: job.JobDetails?.DocumentsWithErrorsCount ?? 0,
        documentsWithError: job.JobDetails?.InputDocumentsCount ?? 0,
      };
    }

    // Not terminal — wait and poll again
    await new Promise(r => setTimeout(r, pollIntervalMs));
  }

  throw new Error(`Translation job ${jobId} did not complete within timeout`);
}

// Usage:
// const jobId = await startTranslationJob({ ... });
// const result = await waitForTranslationJob(jobId);
// if (result.status === 'COMPLETED') {
//   // list files at result.outputS3Uri/<lang>/
// }

The COMPLETED_WITH_ERROR status means some files translated successfully and some failed. Check job.JobDetails.DocumentsWithErrorsCount and look in the output prefix under details/TranslationDetails.json — it lists which input files failed and why (most commonly: file encoding errors, unsupported file format, or individual files that exceeded internal size limits).

JobStatus state machine

StatusMeaningTerminal
SUBMITTEDJob received; waiting for resource allocationNo
IN_PROGRESSTranslation actively runningNo
COMPLETEDAll documents translated successfullyYes
COMPLETED_WITH_ERRORSome documents translated; some failed — check TranslationDetails.jsonYes
FAILEDJob failed before translating any documents (IAM/S3 config error)Yes
STOP_REQUESTEDStopTextTranslationJob called; waiting for in-flight translations to finishNo
STOPPEDJob stopped; partial output may exist in the output prefixYes

The most common cause of FAILED (not COMPLETED_WITH_ERROR) is an IAM configuration error — the data access role cannot read the input S3 URI or write to the output S3 URI. The error message in job.Message includes a hint like AccessDeniedException: ... on s3:ListBucket. Fix the role and resubmit; jobs cannot be retried — you must start a new job.

Listing and stopping jobs

import {
  TranslateClient,
  ListTextTranslationJobsCommand,
  StopTextTranslationJobCommand,
} from '@aws-sdk/client-translate';

const translate = new TranslateClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

// List recent jobs with optional filter
export async function listTranslationJobs(statusFilter?: string) {
  const response = await translate.send(new ListTextTranslationJobsCommand({
    Filter: statusFilter ? { JobStatus: statusFilter as any } : undefined,
    MaxResults: 50,
  }));

  return (response.TextTranslationJobPropertiesList ?? []).map(job => ({
    jobId: job.JobId,
    jobName: job.JobName,
    status: job.JobStatus,
    submittedAt: job.SubmittedTime,
    endAt: job.EndTime,
    sourceLanguage: job.SourceLanguageCode,
    targetLanguages: job.TargetLanguageCodes,
    docsTranslated: job.JobDetails?.TranslatedDocumentsCount,
  }));
}

// Stop an in-progress job
export async function stopTranslationJob(jobId: string) {
  await translate.send(new StopTextTranslationJobCommand({ JobId: jobId }));
  console.log(`Stop requested for job ${jobId} — status will move to STOP_REQUESTED then STOPPED`);
}

IAM permissions for the calling MCP server

In addition to the data access role (which Translate assumes to read/write S3), the IAM principal running your MCP server needs permissions to submit and monitor jobs — and to pass the data access role to Translate.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TranslateBatchJobs",
      "Effect": "Allow",
      "Action": [
        "translate:StartTextTranslationJob",
        "translate:DescribeTextTranslationJob",
        "translate:ListTextTranslationJobs",
        "translate:StopTextTranslationJob"
      ],
      "Resource": "*"
    },
    {
      "Sid": "PassDataAccessRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/translate-data-access-role",
      "Condition": {
        "StringEquals": { "iam:PassedToService": "translate.amazonaws.com" }
      }
    }
  ]
}

The iam:PassRole permission with the iam:PassedToService condition is required whenever you pass a role ARN to an AWS service. Without it, StartTextTranslationJobCommand fails with AccessDeniedException: User is not authorized to perform iam:PassRole. Scope the resource to the specific data access role ARN — do not use * for iam:PassRole.

Error handling reference

ErrorCauseFix
AccessDeniedException on iam:PassRoleCaller lacks iam:PassRole on the data access role ARNAdd iam:PassRole scoped to the role ARN with PassedToService condition
Job status FAILED with S3 errorData access role cannot read input prefix or write output prefixAdd s3:ListBucket + s3:GetObject on input; s3:PutObject on output; trust translate.amazonaws.com
InvalidRequestException at submitS3Uri does not end with /; ContentType mismatch; TargetLanguageCodes contains source languageEnsure S3Uri ends with /; TargetLanguageCodes must not contain the SourceLanguageCode
TooManyRequestsExceptionMore than 5 concurrent jobs per account (default quota)Wait for existing jobs to complete; request quota increase via Service Quotas
ResourceNotFoundExceptionTerminology or parallel data name not foundVerify names with ListTerminologiesCommand; check region match
COMPLETED_WITH_ERROR on individual filesFile too large, encoding error, or unsupported formatCheck details/TranslationDetails.json in output prefix; fix and resubmit affected files