- Access the Alexa Developer Console: Go to https://developer.amazon.com/alexa/console/ask.
- Create a New Skill: Click on Create Skill, give it a name, and choose your preferred language.
- Choose a Template: Select the "Start from Scratch" template and leave the rest as the default.
Once your skill is created, you will be taken to the dashboard. Here's what you need to configure:
-
Skill Invocation Name: This is the word or phrase you'll use to open your skill. It's what you say to your Echo device (e.g., "Alexa, open my awesome assistant"). This command launches the skill, which in turn runs your n8n workflow and starts a conversation with Gemini.
-
Interaction Model -> Intents: This is where you define what actions your skill can perform.
- You can delete the default
HelloWorldIntent. - Click Add intent to create a new one.
- Name the new intent
LLMIntentand click Create custom intent.
- You can delete the default
-
Configure the Intent Slot: A slot is a variable that captures information from the user's request.
- In the
LLMIntentsettings, scroll down to the Intent Slots section and click the plus icon to add a new slot. - Name this slot
question. - Set the Slot Type to
AMAZON.SearchQuery. This type is perfect for capturing open-ended questions. - Click Edit Dialog and enable the "Is this slot required to fulfill the intent?" selector. This ensures Alexa will prompt the user if they don't provide a question.
- In the
-
Add Utterances: Utterances are the phrases that trigger your intent.
- In the
LLMIntentsettings, go to the Sample Utterances section. - Add a simple utterance like
gemini {question}. - This setup means that when your skill is active, you'll need to start your questions with "gemini." For example: "Alexa, open my awesome assistant... Gemini, what's the largest planet in the solar system?"
- Pro-Tip: If you want to avoid saying "gemini" for every question, you'll need to add more utterances to cover various conversation starters, like
tell me {question},ask {question},what {question}, and so on.
- In the
To handle the conversation and interface with Gemini, you will need to import a pre-made n8n workflow.
- Download the Workflow: Download the workflow JSON below.
- Import to n8n: In your n8n instance, click the New button in the top-left corner, and then select Import from File. Upload the JSON file you just downloaded.
- Configure Credentials: The imported workflow will require you to add your Gemini API key. Double-click the Gemini node to open its settings and add your credentials.
- More Customizations: Optionally, you can customize the SearXNG, Calendar and Telegram tools to integrate them within the workflow, but that's not strictly required (although it can be very useful for your AI Agent to have Internet, calendar and Telegram to interact with).
The last step is to connect your Alexa skill to your n8n workflow.
- In the left-hand sidebar of the Alexa developer console, click on Endpoint.
- Select the HTTPS radio button.
- In the Default Region field, paste the production webhook URL from your n8n instance. Make sure you've activated the workflow in n8n first!
- Select the option "My development endpoint has a certificate from a trusted certificate authority" (this is the standard for most n8n setups).
- Finally, click Save and then Build at the top of the page.


I added this node to verify whether requests are legit from Alexa before going to the agent.
P.s: you need to have node built in tools allowed: (crypto, https, url)
{ "nodes": [ { "parameters": { "jsCode": "// -------------------------------------------------------------\n// Alexa request verification - per Amazon's spec.\n// Fails closed: any error returns { verified:false, reason, detail }\n// -------------------------------------------------------------\nconst crypto = require('crypto');\nconst https = require('https');\nconst { URL } = require('url'); // explicit - n8n sandbox may not expose URL as a global\n\nconst item = $input.first();\nconst headers = item.json.headers || {};\n\n// n8n lower-cases header names\nconst sigCertChainUrl = headers['signaturecertchainurl'];\nconst signature256 = headers['signature-256'];\n\n// --- Raw body handling ---------------------------------------\n// The signature is computed over the EXACT bytes Alexa sent.\n// Re-serialising a parsed object will not produce the same bytes\n// (key order, whitespace, numeric formatting all differ) and the\n// signature will never validate. Enable \"Raw Body\" on the\n// Alexa Skill Webhook node.\nlet rawBody, parsedBody, rawBodyAvailable = true;\nif (typeof item.json.body === 'string') {\n rawBody = item.json.body;\n parsedBody = JSON.parse(rawBody);\n} else if (item.binary && item.binary.data) {\n rawBody = Buffer.from(item.binary.data.data, 'base64').toString('utf8');\n parsedBody = JSON.parse(rawBody);\n} else {\n parsedBody = item.json.body;\n rawBody = JSON.stringify(parsedBody);\n rawBodyAvailable = false;\n}\n\nconst fail = (reason, detail) => [{\n json: {\n verified: false,\n reason,\n detail: detail || null,\n rawBodyAvailable,\n certUrl: sigCertChainUrl || null,\n body: parsedBody,\n headers,\n }\n}];\n\nif (!rawBodyAvailable) {\n return fail(\n 'Raw body not available on webhook',\n 'Open the Alexa Skill Webhook node, expand Options, and enable \"Raw Body\". Without it the body bytes cannot be re-hashed.'\n );\n}\n\nif (!sigCertChainUrl || !signature256) {\n return fail('Missing SignatureCertChainUrl or Signature-256 header');\n}\n\n// --- Step 1: validate the cert-chain URL --------------------\nlet certUrl;\ntry {\n certUrl = new URL(sigCertChainUrl);\n} catch (e) {\n return fail('Malformed SignatureCertChainUrl', e.message);\n}\n\n// Normalise: strip fragment, resolve dot-segments, collapse //\ncertUrl.hash = '';\nconst segs = [];\nfor (const p of certUrl.pathname.split('/')) {\n if (p === '' || p === '.') continue;\n if (p === '..') { segs.pop(); continue; }\n segs.push(p);\n}\nconst normPath = '/' + segs.join('/');\n\nif (certUrl.protocol.toLowerCase() !== 'https:') return fail('Bad protocol', certUrl.protocol);\nif (certUrl.hostname.toLowerCase() !== 's3.amazonaws.com') return fail('Bad hostname', certUrl.hostname);\nif (!normPath.startsWith('/echo.api/')) return fail('Bad path', normPath);\nif (certUrl.port && certUrl.port !== '443') return fail('Bad port', certUrl.port);\n\n// --- Step 2: download the PEM chain -------------------------\nlet certPem;\ntry {\n certPem = await new Promise((resolve, reject) => {\n const req = https.get(sigCertChainUrl, (res) => {\n if (res.statusCode !== 200) {\n return reject(new Error('HTTP ' + res.statusCode));\n }\n let data = '';\n res.setEncoding('utf8');\n res.on('data', (c) => { data += c; });\n res.on('end', () => resolve(data));\n });\n req.on('error', reject);\n req.setTimeout(5000, () => req.destroy(new Error('timeout')));\n });\n} catch (e) {\n return fail('Cert download failed', e.message);\n}\n\n// --- Step 3: validate the signing (leaf) certificate --------\nlet signingCert;\ntry {\n const first = certPem.match(/-----BEGIN CERTIFICATE-----[\\s\\S]+?-----END CERTIFICATE-----/);\n if (!first) throw new Error('No PEM block found');\n signingCert = new crypto.X509Certificate(first[0]);\n} catch (e) {\n return fail('Invalid signing certificate', e.message);\n}\n\nconst now = Date.now();\nif (now < Date.parse(signingCert.validFrom) ||\n now > Date.parse(signingCert.validTo)) {\n return fail(\n 'Signing certificate expired or not yet valid',\n 'validFrom=' + signingCert.validFrom + ' validTo=' + signingCert.validTo\n );\n}\n\nconst san = signingCert.subjectAltName || '';\nif (!san.includes('echo-api.amazon.com')) {\n return fail('SAN does not contain echo-api.amazon.com', san);\n}\n\n// --- Step 4: verify the signature ---------------------------\nlet sigValid;\ntry {\n const verifier = crypto.createVerify('RSA-SHA256');\n verifier.update(rawBody, 'utf8');\n verifier.end();\n const sigBuf = Buffer.from(signature256, 'base64');\n sigValid = verifier.verify(signingCert.publicKey, sigBuf);\n} catch (e) {\n return fail('Signature verification error', e.message);\n}\nif (!sigValid) {\n return fail('Signature does not match request body');\n}\n\n// --- Step 5: anti-replay timestamp check (<= 150 s) ---------\nconst ts = parsedBody && parsedBody.request && parsedBody.request.timestamp;\nif (!ts) return fail('Missing request.timestamp');\nconst ageSec = Math.abs((Date.now() - Date.parse(ts)) / 1000);\nif (ageSec > 150) return fail('Timestamp outside 150-second tolerance', 'age=' + ageSec.toFixed(1) + 's');\n\n// --- All good: forward the parsed body downstream -----------\nreturn [{\n json: { verified: true, headers, body: parsedBody }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1808, 1056 ], "id": "d30593e6-b769-4b6b-8c7c-3733a0331bc1", "name": "Verify Alexa Request" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "20784bfc-36ed-4965-b8b4-28e28e3dedce", "leftValue": "={{ $json.verified }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -1536, 1056 ], "id": "bd39f390-41be-4fbd-86aa-745c544b285d", "name": "Verification passed?" }, { "parameters": { "content": "## Alexa request verification\n\nPer Amazon's spec every request must be rejected with HTTP 400 if:\n- SignatureCertChainUrl format is invalid\n- signing cert is expired / missing echo-api.amazon.com SAN\n- Signature-256 does not match SHA-256(body)\n- request.timestamp is older than 150 seconds\n\n_\"Raw Body\" must be enabled on the webhook — the signature is over the exact bytes._", "height": 496, "width": 420, "color": 3 }, "type": "n8n-nodes-base.stickyNote", "position": [ -1984, 736 ], "typeVersion": 1, "id": "97e32924-abbf-42c2-8b7f-ed03541a9447", "name": "Sticky Note Verify" } ], "connections": { "Verify Alexa Request": { "main": [ [ { "node": "Verification passed?", "type": "main", "index": 0 } ] ] }, "Verification passed?": { "main": [ [], [] ] } }, "pinData": {}, "meta": { "templateCredsSetupCompleted": true, "instanceId": "133364fc0a0dd2fe312f4305e4826b5dd8b93b560ad8173f9dcb2abd155c7d23" } }