I tried running a TODO API written in HSP3 on AWS Lambda's custom runtime

I tried running a TODO API written in HSP3 on AWS Lambda's custom runtime

I tried implementing and deploying a TODO API as a custom runtime on AWS Lambda using the programming language HSP3. I delegated TLS and signing to the AWS CLI, and handled everything from routing and validation to assembling DynamoDB requests in HSP3.
2026.08.14

This page has been translated by machine translation. View original

Introduction

I'm Fujii (Da) from the Manufacturing Business Technology Department.

Are you familiar with a programming language called HSP (Hot Soup Processor)?

It's a scripting language with BASIC-like syntax that can display a window just by running it without writing anything, making it easy to create games and tools.
Since Japanese documentation and books for children are available, some people may have had this as their first programming language.

https://hsp.tv/

This time, I wrote the backend API for a TODO app in HSP and deployed it to AWS.
Since it runs in the cloud rather than on a personal computer, multiple users can now use a system written in HSP from the internet.

I treated it like a summer vacation science project and threw it at AI with "I wonder if this is possible." Everything except the manual deployment work was left to AI.
It actually worked, so I'm writing this article with a bit of excitement.

Here are 4 things I learned from the experience upfront.

  • The CUI version hsp3cl has neither TLS nor SigV4 signing, so it cannot directly call AWS APIs. I delegated this layer to the AWS CLI
  • Of the 1.5 seconds until a response, approximately 1.47 seconds was spent waiting for the AWS CLI to start and DynamoDB to respond. Routes that don't call DynamoDB take 59 ms
  • Lowering memory below 1024 MB also reduces CPU allocation, so I don't expect the GB-second billing to decrease
  • I got stuck on the fact that variables inside #module are static variables by default, and that you can't return from inside repeat

The artifacts are stored in the following repository.

https://github.com/dafujii/todo-hsp3-lambda

What is HSP3?

The version currently in use is version 3. It's called HSP3 with a 3 appended at the end. The first version was released in 1996, it became version 3 in 2005, and updates are still ongoing.
The source code of the processing system is also published as OpenHSP.

https://github.com/onitama/OpenHSP

What I'll use this time is hsp3cl version 3.7 built for Linux.
It's a CUI version that runs only with standard input/output, without a window.

What I Built

I made an API that allows CRUD operations on TODOs via HTTP. I didn't build a frontend.

https://github.com/dafujii/todo-hsp3-lambda

The architecture is as follows. Three programs run inside Lambda.

Client


API Gateway (HTTP API)


Lambda (Container Image)
 ├─ bootstrap … Handles Runtime API communication
 ├─ hsp3cl    … Routing / Validation / JSON read/write
 └─ AWS CLI   … Handles TLS and signing ──▶ DynamoDB

HSP3 is not directly calling DynamoDB. The background on why AWS CLI was inserted will be explained later.

The verified environment is as follows.

Item Value
HSP3 3.7 (OpenHSP built from source)
Lambda package Container image
Base image public.ecr.aws/lambda/provided:al2023
Architecture x86_64
Memory 1024 MB
AWS CLI 2.33.15 (Python 3.9.25)
API Gateway HTTP API / payload format 2.0
Region ap-northeast-1

I prepared 5 routes.

Route Action On success
GET /todos Get list 200
POST /todos Create 201
GET /todos/{id} Get one item 200
PUT /todos/{id} Update 200
DELETE /todos/{id} Delete 204

There's also an approach of catching everything with $default and parsing the path yourself, but I went with listing out 5 routes.
This way, undefined paths will return 404 at the API Gateway stage. Since Lambda doesn't start, there's no execution cost either.

The body is {"title": "...", "completed": false}. title is required and up to 200 bytes, and completed defaults to false if omitted.
Errors are returned in the form {"message":"..."}. Failed validation returns 400, missing targets return 404, and failed DynamoDB calls return 500.
Details such as PUT replacing both attributes together and making ID conflicts at creation time return 409 are summarized in the repository README.

There's No HSP3 Runtime for Lambda

Lambda has execution environments for each language, called runtimes.
Python, Node.js, Java, Ruby, and others are listed, but of course HSP3 is not there.

This is where custom runtimes come in. Languages not on the list can bring their own execution environments.
By placing a program named bootstrap, Lambda starts it as the entry point. Everything beyond that is up to you.

This isn't a special use case.
Go and Rust also run on this runtime that only has the OS, with a client that communicates with the Runtime API built in.

The bootstrap this time is a shell script.
Its only tasks are handling communication with the Runtime API and passing values that HSP3 can't obtain on its own.

  1. Call invocation/next to get the next event and place it in /tmp/event.json
  2. Write the table name, DynamoDB endpoint, current UTC time, and new ID to /tmp/config.txt
  3. Delete the previous /tmp/response.json, then run the HSP3 program with hsp3cl
  4. POST the /tmp/response.json written by HSP3 directly to the Runtime API

Step 2 is necessary because hsp3cl has no instruction to read environment variables. Since gettime for getting time also depends on the timezone, the current UTC time is finalized here as well.
The ID is obtained by taking 16 bytes from /dev/urandom and writing it in hexadecimal. The 32-digit id and createdAt values that appear in the operation verification described later are determined here.

The reason for deleting the previous response first in step 3 is that the contents of /tmp persist to the next request.
If hsp3cl crashes without deleting it, the previous response will be returned as-is.

Note that the entire system is not restarted for each request.
The bootstrap started once waits in a loop and calls hsp3cl each time an event arrives.

Also, the following 1 line was needed at the top of bootstrap.

export HOME="${HOME:-/tmp}"

hsp3cl passes the return value of getenv("HOME") directly to strlen during initialization. However, the Lambda execution environment doesn't have HOME. It's not in the list of predefined environment variables either.
As a result, a NULL reference causes SIGSEGV, and it crashes with exit code 139 without producing any output. When running locally, the environment provides HOME, so this problem goes unnoticed.

HSP3 Can't Directly Call AWS APIs

When writing Lambda's contents in HSP3, there was a major wall from the start.
hsp3cl can't directly call AWS APIs.

To call AWS APIs, at minimum 2 things are required.

  1. Encrypt with TLS
  2. Sign with SigV4 (append a string calculated from the request content to prove the sender's identity)

After investigating the Linux version of hsp3cl, I found neither was possible.

  • Network instructions are raw TCP sockets only, with no TLS support. Furthermore, the instruction to open a socket is implemented with inet_addr() which takes an IP address directly, so name resolution isn't possible either
  • There are no instructions equivalent to SHA-256 or HMAC-SHA256, so SigV4 signatures can't be calculated
  • There is FFI to call external libraries. However, HSP3 integers are 32-bit and can't hold 64-bit pointers, so it can't be used to call APIs like libcurl that return handles

The paths of setting up TLS, self-implementing signatures and sending over raw TCP, and delegating to libcurl are all blocked.

A decision on approach was needed here. Either implement SHA-256 and HMAC-SHA256 in HSP3 to create signatures from scratch, or delegate that layer to existing tools.
I chose the latter this time.

Delegating Communication to AWS CLI

AWS CLI handles both TLS and signing. If HSP3 can launch this, the problem is solved.

However, this launching didn't go smoothly.

exec for running external programs exists in the Linux version and calls libc's system() internally. Execution itself works.
However, exit codes can't be retrieved. Even when passing a command that exits with exit 3, stat remains 0.
This makes it impossible to determine whether AWS CLI succeeded or failed.

So I decided to directly call libc's system() via FFI.

#uselib "libc.so.6"
#func shell_exec "system" sptr

#uselib is originally a mechanism for calling Windows DLLs, but in the Linux version it's implemented as dlopen.
By opening libc directly, system() can be used as an HSP3 instruction.

This allowed writing on the HSP3 side as follows.

shell_exec "aws dynamodb put-item ..."

The return value of system() is a POSIX wait status, so the exit code for normal termination can be extracted with stat >> 8.
When crashed by a signal, the signal number is in the lower 7 bits, and right-shifting makes it look like 0 (success), so if the lower 7 bits are non-zero, it's treated as a failure (equivalent to WIFSIGNALED check).
Standard output is redirected to a file and read back with noteload.

Not self-implementing what can't be done, delegating that layer to another tool, and handling the layer above yourself. This was the pragmatic approach for this project.

HSP3 Implementation

The Lambda contents are as follows.

	if route == "GET /todos"         : gosub *op_list   : return
	if route == "POST /todos"        : gosub *op_create : return
	if route == "GET /todos/{id}"    : gosub *op_get    : return
	if route == "PUT /todos/{id}"    : gosub *op_update : return
	if route == "DELETE /todos/{id}" : gosub *op_delete : return

It just looks at the event's routeKey and branches to the appropriate handler. Branching with if and jumping with gosub — it became an HSP3-like way of writing.

Beyond this, what I wrote in HSP3 was event parsing, validation, assembling DynamoDB requests, and assembling responses.

Since there was no JSON handling functionality, I implemented that from scratch too. It's in a separate file called json.hsp.
Rather than building a parse result tree, I used an approach that returns byte offsets. Since var type parameters are passed by reference, the entire JSON can be traversed without copying.

Character encoding handles UTF-8 as a byte sequence. Since all JSON structural characters are ASCII, going byte by byte is sufficient.
\uXXXX is decoded to UTF-8 including surrogate pairs.

DynamoDB calls were kept to one per request. As mentioned later, AWS CLI startup is heavy, so this matters.

  • Create: put-item with attribute_not_exists(id) condition
  • Update: update-item with attribute_exists(id) and ReturnValues: ALL_NEW
  • Delete: delete-item with attribute_exists(id) condition

When the condition expression fails, ConditionalCheckFailedException is returned. ID conflicts on creation become 409, and missing targets on update and delete become 404.
This eliminates the need for a read to "check if it exists before writing." The updated values can also be received in the same single call.

AWS CLI Paginates scan by Default

Only the scan for list retrieval didn't work out with a single call when written straightforwardly.

AWS CLI paginates scan by default. DynamoDB's Scan only returns up to 1 MB at a time.
So AWS CLI internally repeats the call until LastEvaluatedKey is exhausted, combining the results into a single JSON.
It's a helpful feature, but I intended to call it only once.

The problem isn't the number of calls, but that there's no limit on output volume.

When I tested with 40 items of 40 KB each, totaling about 1.6 MB, AWS CLI returned all items as a JSON of 1,605,815 bytes.
The HSP3 side reads the received text into a buffer. Since I had allocated 1 MB for it, the JSON was cut off midway, parsing failed, and it returned 500.

routeKey=GET /todos
Could not read Items from scan response
response: status=500

I applied two fixes. Adding --no-paginate to limit to one page, and widening the HSP3 buffer to 4 MB.
Since DynamoDB's JSON representation is larger than the original data due to attribute names and type tags, exactly 1 MB isn't enough.

Measuring again with the same 40 items, AWS CLI's output fit within 1,084,009 bytes with LastEvaluatedKey included, and 200 was returned with 27 items.
The rest is truncated. No mechanism for fetching the continuation is implemented.

The internals of the delegated layer aren't visible from our side.
It's necessary to verify not only whether it will be faster, but also how much it handles automatically.

Operational verification before deploying to AWS was done locally.
I set up amazon/dynamodb-local, wrote integration tests that pass events directly to the HSP3 program without going through the Runtime API, and ran 45 test cases.

Operation Verification

First, let's create.

$ curl -X POST https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos \
    -H 'content-type: application/json' \
    -d '{"title":"Buy milk"}'

{"id":"cee51ed5c3f27eaa624d761372b28bea","title":"Buy milk","completed":false,
 "createdAt":"2026-08-11T13:29:42Z","updatedAt":"2026-08-11T13:29:42Z"}

GET /todos returns the list with the same items wrapped in {"items":[...]}.

On update, createdAt remains the same while only updatedAt changes.

$ curl -X PUT https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5... \
    -H 'content-type: application/json' \
    -d '{"title":"Buy milk and eggs","completed":true}'

{"id":"cee51ed5c3f27eaa624d761372b28bea","title":"Buy milk and eggs","completed":true,
 "createdAt":"2026-08-11T13:29:42Z","updatedAt":"2026-08-11T13:30:24Z"}

createdAt stays at 13:29:42 while updatedAt became 13:30:24.
This confirmed that the 3 attributes touched by UpdateExpression have no effect on the others.

On deletion, 204 is returned, and a subsequent retrieval returns 404.

$ curl -X DELETE https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5...
$ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5...
{"message":"Not Found"}

Japanese text also made the round trip without issues.
It went through the path of decoding an escaped JSON string within an API Gateway event, re-escaping it to pass to DynamoDB, and retrieving it again.

Measuring Latency Breakdown

While it worked, it takes 1.5 seconds to get a response.
There's a clearly noticeable wait after pressing enter with curl before it returns.

At first I thought HSP3 was slow.
However, looking at the REPORT lines in CloudWatch Logs, I noticed that routes not calling DynamoDB were an order of magnitude different.

Sending a body without title causes the backend to return 400 without consulting the database. The actual measurements are as follows.

Route Duration
Cold start (GET /todos) 5677 ms (Init Duration 157 ms)
Routes calling DynamoDB (average of 8) 1534 ms
Routes not calling DynamoDB (just returning 400) 59 ms

The 59 ms route also went through bootstrap receiving the event, hsp3cl execution, event JSON parsing, and response writing.

In other words, of the 1.5 seconds, approximately 1.47 seconds are spent in the path from launching AWS CLI to reading back the DynamoDB response.
HSP3's own execution was not the dominant factor.

However, the 1.47 seconds is a lump sum.
It includes shell startup, AWS CLI (Python) initialization and module loading, credential resolution, SigV4 signing, TLS communication, DynamoDB processing, file I/O for output, and the processing for HSP3 to read back and convert the response.
Since it's just a difference, the breakdown within that isn't known.

A clue came from measurements taken locally with CPU limits applied.
Even in a state where credentials were missing and communication couldn't be reached, a single AWS CLI execution took several seconds (this time also includes the process of looking for credentials). What's heavy is the startup side, not the communication.

The cold start of 5677 ms was the same story.
Duration and Init Duration are separate items, and Init is also subject to billing (this was already the case for custom runtimes, but since August 2025, all configurations including managed runtimes are subject). The actual billed time can be confirmed in the Billed Duration in the REPORT line.
Init is only 157 ms, so runtime initialization isn't dominant. The extra ~4 seconds are on the handler's first invocation side.

What makes this slightly complicated is that AWS CLI restarts a new process for each request.
Since Python and module loading happen every time, that's not the reason for the extra 4 seconds only on the first call.
The suspicious part is the cost of first touching the 200+ MB of files from the image. However, this measurement couldn't determine that.

If I had gone ahead trying to fix things based on assumptions, I would have ended up endlessly staring at HSP3 code.
It was fortunate that I happened to have a route available that could be used for isolation.

By the way, the size of the artifacts is as follows.

Item Size
HSP3 program written this time (intermediate code) 14 KB
hsp3cl executable 278 KB
Bundled AWS CLI Over 200 MB
Container image overall 555 MB

The app itself is 14 KB. In terms of file size, the surrounding components are more than 10,000 times larger.

Does Reducing Memory Make It Cheaper?

Lambda had 1024 MB of memory allocated. Looking at Max Memory Used, actual usage is 126 MB.
That's only about 10% of the allocated amount.

Since it's not being used, reducing to 256 MB should lower the cost. That was my thinking.

The proper approach would be to measure with each memory setting on real hardware, but let's make an estimate locally first.
The result was the opposite of my hypothesis. There are two reasons.

The first is the mechanism where Lambda allocates CPU proportional to memory allocation (approximately 1 vCPU at 1769 MB).
A large portion of the 1.47 seconds should be the time for AWS CLI to start Python and load modules. In other words, it's CPU-bound processing.

Let's measure locally by applying only CPU limits to the same image at the same ratio as Lambda.

Lambda memory equivalent --cpus AWS CLI startup time Ratio vs 1024
1024 MB 0.579 3469 ms 1.0x
512 MB 0.289 9764 ms 2.8x
256 MB 0.145 40798 ms 11.8x

Half gives 2.8x, a quarter gives 11.8x. It's not linear — the more you reduce, the more the disadvantage increases.

Note that this is an alternative measurement using Docker's CFS quota, which is a different mechanism from Lambda's CPU allocation.
Since locally it's amd64 emulation on Apple Silicon, the absolute values are also larger than on real hardware. The ratio is what matters.

The second reason is the billing formula. Lambda billing is GB-seconds of memory × execution time.
If execution time becomes more than 4x with memory at a quarter, the product won't decrease. It won't just fail to get cheaper — it will get more expensive.

Furthermore, at 256 MB, there's concern about cold starts hitting the timeout.
The real-hardware cold start was 5.7 seconds, and most of that is the first AWS CLI execution. The ratio only applies to the CPU-determined portion, so let's keep the extra ~4 seconds from before and apply the calculation only to the warm 1.47 seconds.
Even applying 11.8x just to the 1.47 seconds, the cold start would exceed 20 seconds. That's approaching the function's Timeout: 29 and the integration timeout upper limit of 30 seconds for API Gateway (HTTP API).

As long as AWS CLI initialization is a CPU-determined process, reducing memory extends execution time proportionally, and GB-seconds won't decrease.
No justification was found for going down to 256 MB, so I kept it at 1024 MB. Measuring each memory setting 20 times on real hardware and comparing median warm-start, 95th percentile, cold start, and GB-seconds would give more definitive answers. I didn't go that far this time.

To truly make it faster and cheaper, what should be eliminated is not memory but the AWS CLI itself on the request path.
If SigV4 is implemented in HSP3 and the curl command is called via shell_exec, most of the 1.47 seconds would disappear, and then reducing memory would actually lower costs.
Since SHA-256 and HMAC-SHA256 need to be written in HSP3, that would be another science project.

Two Places Where I Got Stuck on HSP3 Language Specifications

This is where I spent the most time.

When I tried running the program I had written, only empty results came back no matter what I sent.
No errors. The process didn't crash. It just couldn't find anything.

When this happens, you want to start making random changes, but then you can't tell whether it's actually fixed or not.
I extracted only the suspicious parts into small programs and verified one condition at a time.
There were two causes, and both were unexpected.

First: Variables Inside Modules Were Static Variables

In HSP3's #module, variables used inside functions are static variables shared within the module by default.

This means that if functions are called in a nested fashion, the callee overwrites the caller's variables.
The variable holding "where we're currently reading" in the JSON parser got corrupted this way, and it kept looking at completely wrong positions.

Work variables need to be declared with local in the parameter list.

#defcfunc local jl_strend var s, int p, local i, local r, local c

The latter part local i, local r, local c is that declaration. The caller doesn't pass actual arguments for these.

Second: You Must Not return from Inside repeat

In HSP3's repeat ~ loop, return-ing from inside doesn't release the loop stack.

What makes it tricky is that it doesn't crash immediately.
After a few calls, error 30 (Invalid parameter name) or error 9 (Too many nesting) appears at a completely unrelated location.
Looking at the line where the error occurred, the cause isn't there.

It was fixed by storing the result in a variable and using break, then return-ing after exiting the loop.

Additionally, I stepped on the following points.

  • Instructions declared with #func without global outside a module will cause a compile error when called from within #module. Adding #func global or declaring within the module allows calling it, but this time I consolidated shell execution into subroutines in the global scope
  • Passing parameters received as int or str in #deffunc directly to var arguments of #func causes a runtime error
  • \n in string literals is expanded to CR+LF. Since JSON's \n is LF, comparisons won't match as-is

Blocking Shell Injection

system() passes the string to /bin/sh -c.
Concatenating request-derived values into the command string creates a shell injection vulnerability at that point.

For example, suppose the following value is sent in title.

; touch /tmp/pwned; echo $(whoami)

Since ; is a command separator, if constructed naively, it would be executed as a command.
A string intended as data transforming into an instruction — it's the same structure as SQL injection.

The countermeasure is to not put values into the command string.
All values passed to DynamoDB are written out to a JSON file and passed in the form --cli-input-json file:///tmp/ddb_req.json. The table name is also placed in this file.

Shell usage isn't just for DynamoDB calls. API Gateway sometimes sends bodies in base64, and that decoding is also delegated to the base64 command.
Same approach here — the string to be decoded is written to a file and read via redirect (base64 -d < /tmp/body_b64.txt).

As a result, only subcommand names and options fixed on the code side, plus the endpoint URL determined at deployment time, go into the command string.
This URL is enclosed in single quotes. The rationale isn't "it's safe because it's not request-derived," but rather only values that attackers cannot modify are placed, and those are quoted.

I confirmed through tests that sending the above string doesn't execute the command and that the value is stored as a string as-is.

However, these tests only look at one specific string sent.
And the policy of "don't put values in the command string" can easily be broken just by modifying the code. Even if notes and comments are left, there's no guarantee the person modifying it will read them.

So I added a static check to the build that compares lines involved in constructing the string passed to system() against an allowlist.
If lines assembling commands increase or change, the build stops before compilation.
Since the build won't pass without updating the allowlist, whenever this path is touched, a person must once review whether request-derived values are mixed in. This avoids leaving it to chance whether written notes will be followed.

In addition, the execution role's permissions for DynamoDB are restricted to just the 5 of GetItem / PutItem / UpdateItem / DeleteItem / Scan, targeting only the ARN of this project's table (plus standard policies for log output).
Even if unexpected arguments are passed to AWS CLI, other tables cannot be touched.

What I Didn't Build This Time

Parts intentionally left out as beyond the scope of this science project.

  • Authentication and authorization: It's published in a state where anyone can call it. For production use, JWT authorizers or IAM authentication would need to be considered.
    Note that AWS WAF can't be attached directly to HTTP API, so it would need to be placed via CloudFront in the front. In that case, bypassing by calling the execute-api endpoint directly also needs to be blocked.
    To limit damage in case of runaway usage, Lambda concurrent executions are limited to 5 and API throttling to 5 per second. However, these are settings to minimize damage, not cost caps
  • Fetching continuation of list: Since --no-paginate limits Scan to one page, anything exceeding 1 MB isn't returned. Even if LastEvaluatedKey is in the response, it's ignored and truncated.
    Moreover, since 200 is returned, clients can't tell that the list is incomplete. At minimum, whether there's a continuation should be returned.
    For use cases where item counts grow, a mechanism to fetch continuations or redesigning the table (GSI and Query) would be needed
  • Strong consistency reads: GET and list use DynamoDB's default eventually consistent reads. Retrieving immediately after writing may rarely still show the state before the change.
    Enabling ConsistentRead would provide consistency, but increases read costs
  • Update conflict handling: PUT simply overwrites existing items, so simultaneous updates to the same item result in last-write-wins

Try It Out

The code for this project is in the repository.

https://github.com/dafujii/todo-hsp3-lambda

Since the HSP3 processing system is built from source inside the Dockerfile, you don't need to install HSP3 locally.
For local testing only, you just need Docker; for deploying, you additionally need the AWS CLI.

To try without an AWS account, run ./local-test.sh.
It sets up DynamoDB Local and automatically verifies 45 test cases including a CRUD cycle, validation, Japanese round-trip, and confirming shell injection doesn't work. No billing occurs.

To deploy to AWS, run ./deploy.sh.
Setting AWS_REGION and AWS_ACCOUNT_ID and running it will go through creating an ECR repository, pushing the image, and deploying DynamoDB, Lambda, and API Gateway.
If the specified account and actual credentials don't match, it stops at the beginning.

The deployed API is published without authentication. Please make sure to delete it after testing.
Deleting 2 stacks will clean everything up. Instructions are in the repository README.
If left running, unknown parties will use it, and charges will accrue beyond the free tier.

Closing

I was able to write a TODO app backend in HSP3 and run it on AWS.

Routing, validation, JSON read/write, and assembling DynamoDB requests are handled by the HSP3 script. Only TLS and signing were delegated externally.
My impression was that it was a language for displaying windows, but it's running as a web API without any window at all.

Getting it to work involved crashes from just missing HOME, and corruption from variables being shared inside modules.
Still, if you hand off the layers you can't handle to other tools, you can write the layer above in HSP3.

To reiterate, if you deployed to your own AWS account, please delete it after testing. It will remain published without authentication.

I tried asking AI if it could be done with HSP and left everything to it, and it actually implemented a TODO API including everything described in this blog post and brought it to a deployable state on AWS.
I was surprised in two senses: "HSP can do this?" and "AI can do this much?"

I hope this has made you think, "so HSP can do things like that."

Share this article