Real-Time Voice to Sign Language Translation - Part 2: Cloud Infrastructure with AWS CDK, IoT Core, and AppSync
This is Part 2 of a 3-part series covering a real-time voice-to-sign-language translation system. In Part 1, I covered the React frontend that captures speech, processes it with Amazon Nova 2 Sonic, and publishes cleaned sentence text via MQTT. But there is a missing piece — how does the frontend know what the physical hand is actually doing?
The answer is this repository: a small but critical AWS CDK stack that acts as the bridge between the edge device and the React frontend. It routes real-time hand state data from IoT Core to AppSync, enabling the frontend to receive live updates via GraphQL subscriptions — so the 3D hand animation stays synchronised with the physical Amazing Hand — an open-source robotic hand designed by Pollen Robotics and manufactured by Seeed Studio.
The three repositories in the series:
- Part 1 - Frontend and Voice Processing (
amplify-react-nova-sonic-voice-chat-amazing-hand) — React web app that captures speech, streams to Nova 2 Sonic, publishes cleaned sentence text via MQTT - This post (Part 2) - Cloud Infrastructure (
cdk-iot-amazing-hand-streaming) — AWS CDK stack that routes IoT Core messages through Lambda to AppSync for real-time GraphQL subscriptions - Part 3 - Edge AI Agent (
strands-agents-amazing-hands) — Strands Agent powered by Amazon Nova 2 Lite on NVIDIA Jetson that translates sentence text to ASL servo commands, drives the Amazing Hand, and publishes state back
Goals
- Route real-time hand state data from IoT Core MQTT to AppSync using an IoT Rules Engine SQL query and Lambda
- Flatten nested MQTT finger angle payloads into a flat GraphQL schema for the
createHandStatemutation - Enable the React frontend to receive live hand state updates via AppSync
onCreateHandStateGraphQL subscriptions - Extract the device name dynamically from the MQTT topic path using
topic(3)in the IoT Rule SQL - Define all infrastructure as code using AWS CDK in TypeScript
- Integrate with the existing Amplify Gen 2 managed AppSync API and DynamoDB table from Part 1
The Overall System
This diagram shows the complete end-to-end system. Part 2 is the infrastructure highlighted in the middle — the IoT Rule, Lambda, and AppSync connection that enables real-time state feedback from the edge device back to the frontend.

How Part 2 fits in:
- Part 1 (Frontend) publishes cleaned sentence text to
the-project/robotic-hand/{deviceName}/actionand subscribes to AppSynconCreateHandStatefor live updates - Part 3 (Edge Device) receives sentence text, translates it to ASL servo commands via the Strands Agent powered by Amazon Nova 2 Lite, drives the Amazing Hand, and publishes state back to
the-project/robotic-hand/{deviceName}/state - Part 2 (This stack) listens on the
/statetopic, transforms the payload, and pushes it into AppSync — completing the real-time feedback loop
Architecture
The stack is intentionally small — a single IoT Rule, a single Lambda function, and the IAM glue to connect them. The AppSync API and DynamoDB table are managed by the Amplify Gen 2 backend in Part 1, so this stack only needs to call the existing createHandState mutation.
Infrastructure Overview

Resources created by this CDK stack:
- IoT Topic Rule (
AmazingHandStateStreamingRule) — Matches MQTT messages onthe-project/robotic-hand/+/stateusing SQLSELECT gesture, letter, ts, fingers, video_url, topic(3) AS device_name, then invokes the Lambda function - Lambda Function (
AmazingHandToAppSyncFunction) — Node.js 18 function that receives the IoT event, flattens the nestedfingersobject into individual angle fields, and calls the AppSynccreateHandStateGraphQL mutation using the Amplify v6 SDK with API Key authentication - Lambda IAM Role — Service role with
AWSLambdaBasicExecutionRolefor CloudWatch Logs and an inline policy grantingappsync:GraphQLon the AppSync API - Lambda Permission — Allows the IoT service (
iot.amazonaws.com) to invoke the Lambda function
Resources managed externally (by Amplify Gen 2 in Part 1):
- AppSync API — GraphQL API with
HandStatemodel,createHandStatemutation, andonCreateHandStatesubscription - DynamoDB Table —
HandStatetable with auto-generated resolvers from the@modeldirective
Data Flow
Interactive Sequence Diagram
IoT Core to AppSync Data Flow
From edge device MQTT publish to React real-time subscription update
How it works
IoT Rules Engine SQL Query
The IoT Rule is the entry point. It listens on the MQTT topic pattern the-project/robotic-hand/+/state where + is a single-level wildcard matching any device name (e.g. XIAOAmazingHandRight).
The SQL query (using AWS IoT SQL version 2016-03-23) selects specific fields from the MQTT payload and enriches them with metadata extracted from the topic path:
SELECT gesture, letter, ts, fingers, video_url, topic(3) AS device_name
FROM 'the-project/robotic-hand/+/state'
gesture— The type of sign being performed (e.g. "fingerspell")letter— The current letter being signed (e.g. "E")ts— Unix timestamp in secondsfingers— Nested JSON object containing servo angles for all four fingers, each with two joint anglesvideo_url— Optional pre-signed S3 URL for video of the hand in actiontopic(3) AS device_name— Extracts the 3rd segment of the MQTT topic path as the device name, so the Lambda does not need to parse the topic itself
MQTT Payload Format
The edge device publishes hand state messages in this format:
{
"gesture": "fingerspell",
"letter": "E",
"ts": 1770550850,
"fingers": {
"index": { "angle_1": 45, "angle_2": -45 },
"middle": { "angle_1": 45, "angle_2": -45 },
"ring": { "angle_1": 45, "angle_2": -45 },
"thumb": { "angle_1": 60, "angle_2": -60 }
},
"video_url": "https://cc-amazing-video.s3.amazonaws.com/videos/hand_20260228.mp4?..."
}
The fingers object uses a nested structure with angle_1 and angle_2 per finger — representing the two joints of each finger on the Amazing Hand. This nested format is natural for the edge device to produce but needs to be flattened for the GraphQL schema.
Lambda Function — Payload Transformation
The Lambda function (AmazingHandToAppSyncFunction) receives the IoT event with the enriched fields from the SQL query. Its job is to:
- Validate that the
device_namefield exists (required for the GraphQL mutation) - Flatten the nested
fingersobject into individual angle fields:
| MQTT Payload | GraphQL Field |
|---|---|
fingers.index.angle_1 | indexAngle1 |
fingers.index.angle_2 | indexAngle2 |
fingers.middle.angle_1 | middleAngle1 |
fingers.middle.angle_2 | middleAngle2 |
fingers.ring.angle_1 | ringAngle1 |
fingers.ring.angle_2 | ringAngle2 |
fingers.thumb.angle_1 | thumbAngle1 |
fingers.thumb.angle_2 | thumbAngle2 |
- Default missing angle values to
0, missing gesture/letter/videoUrl tonull, and missing timestamp toMath.floor(Date.now() / 1000) - Call AppSync
createHandStatemutation using the Amplify v6 SDK configured with API Key authentication
The Lambda uses the Amplify v6 SDK (aws-amplify@^6.0.0) to call AppSync, configured via environment variables:
Amplify.configure({
API: {
GraphQL: {
endpoint: process.env.APPSYNC_API_URL,
region: process.env.AWS_REGION,
defaultAuthMode: 'apiKey',
apiKey: process.env.APPSYNC_API_KEY
}
}
});
GraphQL Mutation
The Lambda calls this mutation to persist the hand state and trigger the real-time subscription:
mutation CreateHandState($input: CreateHandStateInput!) {
createHandState(input: $input) {
id
deviceName
gesture
letter
indexAngle1
indexAngle2
middleAngle1
middleAngle2
ringAngle1
ringAngle2
thumbAngle1
thumbAngle2
timestamp
videoUrl
createdAt
}
}
When AppSync receives this mutation, two things happen:
- The hand state record is persisted to DynamoDB via the auto-generated
@modelresolver - The
onCreateHandStatesubscription is triggered, pushing the new record to all subscribed clients — including the React frontend from Part 1, which uses this data to update the 3D hand animation, signed letter history, and video feed in real-time
