Interacting with Cloud services is rarely a tactile experience. You write some code, run some command or click a button on a screen and things happen. Today we're going to change that. We'll write a plugin for the Elgato Stream Deck to trigger an AWS Lambda function on demand with a customizable event.
In case you haven't heard of it, the Stream Deck is basically a set of buttons with tiny screens behind them that you can customize to do your bidding through plugins. Today, we'll write a small plugin that invokes a Lambda function of our choosing, thereby allowing us to do pretty much anything in AWS.
My first attempt at this a couple of years ago was not very successful. Since then I've gained more Typescript experience and the state of the Stream Deck SDK has improved substantially, so I gave it another shot.
Let's dive into it. Stream Deck plugins are small Javascript / Typescript apps that talk to the Stream Deck app via a Websocket connection. This means they mostly respond to events such as a button being pressed or an action being added to a Stream Deck. They can also initiate actions or monitor system state and respond to that, but for now we'll stick to simple interaction patterns. Writing plugins isn't that hard if you're a bit familiar with the Typescript ecosystem.
You can find the code for this project here on Github.
For this project, I'm going to assume that you have the AWS CLI configured (i.e. credentials are already prepared) and NodeJS installed (I'm using v24.2.0, minimum is version 20). Also, you need a Stream Deck and ideally VS Code. The prerequisites are also covered in the documentation. Next, we're going to install the Stream Deck CLI, which provides a scaffolding for our plugin and helps with the Stream Deck integration.
npm install -g @elgato/cli
Once that's complete, we're ready to create our plugin using streamdeck create
. I'm going to call mine AWS Lambda Invoke with the UUID com.mauricebrg.lambda-invoke
- you should probably use your own Domain or UUID to avoid conflicts.
$ streamdeck create
___ _ ___ _
/ __| |_ _ _ ___ __ _ _ __ | \ ___ __| |__
\__ \ _| '_/ -_) _` | ' \ | |) / -_) _| / /
|___/\__|_| \___\__,_|_|_|_| |___/\___\__|_\_\
Welcome to the Stream Deck Plugin creation wizard.
This utility will guide you through creating a local development environment for a plugin.
For more information on building plugins see https://docs.elgato.com.
Press ^C at any time to quit.
✔ Author: MauriceBrg
✔ Plugin Name: AWS Lambda Invoke
✔ Plugin UUID: com.mauricebrg.lambda-invoke
✔ Description: Invokes Lambda functions from the Stream Deck
✔ Create Stream Deck plugin from information above? Yes
Creating AWS Lambda Invoke...
✔ Enabling developer mode
✔ Generating plugin
✔ Installing dependencies
✔ Building plugin
✔ Finalizing setup
Successfully created plugin!
✔ Would you like to open the plugin in VS Code? Yes
When that's done, it should open VS Code and the folder structure that you'll see looks something like this. The wizard has helpfully provided an example action that implements a counter. This shows us how to lay out our code. I've added some comments to explain what's located where.
$ tree --gitignore -I node_modules
.
├── com.mauricebrg.lambda-invoke.sdPlugin
│ ├── bin # Content gets regenerated automatically
│ │ ├── package.json
│ │ └── plugin.js
│ ├── imgs # (Image) Assets for use by the plugin
│ │ ├── actions
│ │ │ └── counter
│ │ │ ├── icon.png
│ │ │ ├── [email protected]
│ │ │ ├── key.png
│ │ │ └── [email protected]
│ │ └── plugin
│ │ ├── category-icon.png
│ │ ├── [email protected]
│ │ ├── marketplace.png
│ │ └── [email protected]
│ ├── manifest.json # Describes the actions we provide
│ └── ui # UI for the action settings
│ └── increment-counter.html # Sample action settings
├── package-lock.json
├── package.json # Defines our package, dependencies & scripts
├── rollup.config.mjs # Configuration for the bundler
├── src
│ ├── actions
│ │ └── increment-counter.ts # Sample action implementation
│ └── plugin.ts # Configuration for the plugin
└── tsconfig.json
Before we start customizing things, I should point out that this setup comes with two scripts prepared for us:
-
npm run build
to turn our Typescript code into Javascript so it can be used by the Stream Deck app -
npm run watch
which executes thebuild
command once we change anything in the code
You can already go ahead and run npm run watch
in a separate Terminal session. If you open the Stream Deck app, you should already see a new action, which you can add to your Stream Deck and interact with.
Next, we'll add the new folder com.mauricebrg.lambda-invoke.sdPlugin/imgs/actions/lambda
and place an SVG with the Lambda logo from the official AWS Icon pack there. This allows us to reference a custom image later on. Now we'll start some preparations for us to invoke the Lambda function. We'll need to install the AWS SDK for that, but that requires another plugin for the bundler, since rollup seems to treat the AWS SDK as an ES Module, which it isn't. To work around that, we'll install the JSON plugin for rollup using npm i --save-dev @rollup/plugin-json
and edit rollup.config.mjs
:
// rollup.config.mjs
import json from "@rollup/plugin-json";
// ...
const config = {
output: {
// Other settings
inlineDynamicImports: true
}
// ...
plugins: [
json(),
// Other plugins
]
}
Now we can run npm i --save @aws-sdk/client-lambda
to install the AWS SDK without further issues. Okay, all of this has been in preparation for our new action. We'll start by defining the code. Create a new file src/actions/invoke-async.ts
with this content:
// src/actions/invoke-async.ts
import streamDeck, { action, KeyDownEvent, SingletonAction } from "@elgato/streamdeck";
const logger = streamDeck.logger
@action({UUID: "com.mauricebrg.lambda-invoke.invoke-async"})
export class InvokeAsync extends SingletonAction<InvokeAsyncSettings> {
// This function is called when the button is pressed
public override async onKeyDown(ev: KeyDownEvent<InvokeAsyncSettings>): Promise<void> {
logger.info(ev)
}
}
type InvokeAsyncSettings = {
lambdaSettings?: LambdaInvocationSettings
}
type LambdaInvocationSettings = {
profile: string // Which SDK Creds to use
region: string // Region that Lambda is located in
functionName: string // Name of the function to execute
event: string // Payload to send to Lambda
}
The code defines a class which extends the SingletonAction
from the Stream Deck SDK. Additionally it implements a handler for the onKeyDown
event that is emitted whenever the key is pressed. At this point we only log the event. You can also find some data structures that define which settings we'll eventually need for this to work. We need to configure which SDK config to use to talk to AWS and in what region to invoke which Lambda with which event.
At this point our code is not executed. For that to happen, we need to extend the plugin.ts
to register our action as well as the manifest.
// src/plugin.ts
// ...
import { InvokeAsync } from "./actions/invoke-async";
// ...
streamDeck.actions.registerAction(new InvokeAsync())
// ... (insert above before this block)
// Finally, connect to the Stream Deck.
streamDeck.connect();
This tells the Stream Deck where to find the backend code and in the manifest we tell it metadata about our action.
// com.mauricebrg.lambda-invoke.sdPlugin/manifest.json
{
// ...
"Actions": [
{
"Name": "Async Invocation",
"UUID": "com.mauricebrg.lambda-invoke.invoke-async",
"Icon": "imgs/actions/lambda/lambda",
"Tooltip": "Invokes a Lambda Function asynchronously",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/lambda/lambda",
"TitleAlignment": "middle"
}
]
},
//... other actions
]
// ...
}
If you look closely, you can see the references to the lambda.svg
we created earlier in the Icon
and States.Image
keys. You should now be able to see new action in the UI. Place it on the Stream Deck and press the button.
Pressing the button doesn't to anything in the UI (yet) and the settings also don't match what we're trying to do - we'll fix both of those in a minute. First, let's look at the logs, because there our action was registered. I've abbreviated the logs a bit so it's not completely overwhelming.
// com.mauricebrg.lambda-invoke.sdPlugin/logs/com.mauricebrg.lambda-invoke.0.log
TRACE Connection: {"action":"com.mauricebrg.lambda-invoke.invoke-async","context":"...","device":"...","event":"keyDown","payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}
INFO {"type":"keyDown","action":{"controllerType":"Keypad","device":{"id":"..."},"id":"...","manifestId":"com.mauricebrg.lambda-invoke.invoke-async","coordinates":{"column":2,"row":2},"isInMultiAction":false},"payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}
TRACE Connection: {"action":"com.mauricebrg.lambda-invoke.invoke-async","context":"...","device":"...","event":"keyUp","payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}
The log level is currently set to TRACE
in src/plugin.ts
, which lets us see all communication between the Stream Deck and our Plugin. We can see that two events have happened, first a keyDown
and last a keyUp
event. Additionally we can see which button was pressed on what device through the coordinates and various identifiers. In between we can see our INFO
log with the event that our handler received.
Settings are also passed with each event. These are what allow us to go from stateless to stateful. Essentially, settings are a JSON object that is passed to each event listener and can be read and updated from there. Per-action and global (plugin-level) settings exist and today we'll focus on the former. We can provide a UI to interact with the settings an example of which you can see in form of the slider in the screenshot above.
The UI for settings is based on Stream Deck Plugin web components. We can use them to add per-action configuration options. Since we need profile
, region
, functionName
, and event
for our action, I've gone ahead and created a template that allows the user to enter this data (in the most basic way possible). Here's an excerpt:
<!-- com.mauricebrg.lambda-invoke.sdPlugin/ui/invoke-async.html -->
<!DOCTYPE html>
<html>
<head lang="en">
<title>Async Invocation Settings</title>
<meta charset="utf-8" />
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="AWS Profile">
<sdpi-textfield
setting="lambdaSettings.profile"
placeholder="default"
default="default"
required>
</sdpi-textfield>
</sdpi-item>
<!-- ... -->
<sdpi-item label="Event">
<sdpi-textarea
setting="lambdaSettings.event"
rows="5"
showlength
default="{}"
required>
</sdpi-textarea>
</sdpi-item>
</body>
</html>
Looking at the setting
attribute in the Input definitions, you can probably already tell that this is the path where the value of the input is made accessible to the plugin. In the UI, the HTML is rendered like this:
Finally we have all the plumbing in place to add the logic. First, we add a helper function to parse the settings and add default values for anything not explicitly configured. The second function invokes a Lambda asynchronously based on the settings.
import { LambdaClient, InvokeCommand, InvocationType } from "@aws-sdk/client-lambda";
// ...
// Add default values if the settings are not set.
function parseSettingsAndAddDefaults(settings: InvokeAsyncSettings): InvokeAsyncSettings {
return {
lambdaSettings: {
region: settings.lambdaSettings?.region ?? "eu-central-1",
functionName: settings.lambdaSettings?.functionName ?? "PythonDemo",
event: settings.lambdaSettings?.event ?? "{}",
profile: settings.lambdaSettings?.profile ?? "default"
}
}
}
// Invoke the Lambda function based on our settings
async function invokeLambdaAsync(config: LambdaInvocationSettings): Promise<number> {
const client = new LambdaClient({region: config.region, profile: config.profile})
const command = new InvokeCommand({
FunctionName: config.functionName,
InvocationType: InvocationType.Event
})
try {
const response = await client.send(command)
logger.info(response)
return response.StatusCode ?? 500
} catch (error) {
logger.error("Failed to invoke Lambda", error)
return 500
}
}
The last bit of logic adds the implementation of the onKeyDown
event handler. We parse the configured settings while adding defaults for anything that's missing and then call the Lambda function. Any return codes in the 200 range are considered good, everything else concerning. Here, showOk
will display a ✅ on the key for a few seconds and showAlert
will display ⚠️ for a couple of seconds.
// ...
@action({UUID: "com.mauricebrg.lambda-invoke.invoke-async"})
export class InvokeAsync extends SingletonAction<InvokeAsyncSettings> {
// This function is called when the button is pressed
public override async onKeyDown(ev: KeyDownEvent<InvokeAsyncSettings>): Promise<void> {
const settings = parseSettingsAndAddDefaults(await ev.action.getSettings())
// Now we know that the lambda settings aren't empty
const statusCode = await invokeLambdaAsync(settings.lambdaSettings!)
await ev.action.setTitle(`Response\n${statusCode}`)
if (200 <= statusCode && statusCode < 300) {
await ev.action.showOk()
} else {
await ev.action.showAlert()
}
}
}
// ...
This is it. Once you press the button, it will invoke the Lambda function and show the return code of the invoke API. Note that this is not the return code of the Lambda itself as the invocation is asynchronous. There's a long list of things that could be improved in terms of error handling and better feedback to the user, but we're already running long here, so let's finish up. Before we'll bundle up our plugin, let's remove the sample action and its assets. Delete the files from the following list, remove the instantiation + import from src/plugin.ts
and remove the action from the manifest.json
.
com.mauricebrg.lambda-invoke.sdPlugin/imgs/actions/counter/*
com.mauricebrg.lambda-invoke.sdPlugin/ui/increment-counter.html
src/actions/increment-counter.ts
Next, we check that everything is fine by running the validator on the plugin:
$ streamdeck validate com.mauricebrg.lambda-invoke.sdPlugin
✔ Validation successful
Finally, we packaged it into a distributable plugin:
$ streamdeck pack com.mauricebrg.lambda-invoke.sdPlugin
📦 AWS Lambda Invoke (v0.1.0.0)
Plugin Contents
├─ 993 B manifest.json
├─ 1.3 kB ui/invoke-async.html
├─ 6.1 kB imgs/.DS_Store
├─ 1.1 kB imgs/plugin/category-icon.png
├─ 2.4 kB imgs/plugin/[email protected]
├─ 53.1 kB imgs/plugin/marketplace.png
├─ 123.1 kB imgs/plugin/[email protected]
├─ 6.1 kB imgs/actions/.DS_Store
├─ 6.1 kB imgs/actions/lambda/.DS_Store
├─ 1.8 kB imgs/actions/lambda/lambda.svg
├─ 20 B bin/package.json
└─ 917.9 kB bin/plugin.js
Plugin Details
Name: AWS Lambda Invoke
Version: 0.1.0.0
UUID: com.mauricebrg.lambda-invoke
Total files: 12
Unpacked size: 1.1 MB
File name: com.mauricebrg.lambda-invoke.streamDeckPlugin
✔ Successfully packaged plugin
lambda-invoke/com.mauricebrg.lambda-invoke.streamDeckPlugin
You can now distribute the com.mauricebrg.lambda-invoke.streamDeckPlugin
file from the directory as your plugin. If you want to publish it on the Elgato Marketplace, you can read more about it in the documentation. You can find the complete code on Github, feel free to extend it or build your own actions. Here are some suggestions what to do next:
- Implement a synchronous Lambda invocation
- Add a linter, prettier and JSDoc strings as explained in the docs
I hope you enjoyed this post and learned something new.
— Maurice
Top comments (0)