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 a programming language called HSP3. I delegated TLS and signing to the AWS CLI, while handling 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 of you may have had this as your 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 local computer, multiple users can now use a system written in HSP over the internet.

I approached it as a summer vacation independent study project, asking AI "I wonder if this is possible" and leaving everything to AI except for the manual deployment work.
Since it actually worked, I'm writing this article with a touch of emotion.

Here are 4 things I learned from the experience, listed 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 were spent on AWS CLI startup and waiting for DynamoDB responses. Routes that don't call DynamoDB take 59 ms
  • Lowering memory below 1024 MB also reduces CPU allocation, so I don't expect 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 deliverables 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 at the end. The first version was released in 1996, version 3 came out in 2005, and updates are still continuing.
The source code of the processor is also published as OpenHSP.

https://github.com/onitama/OpenHSP

What I'll be using this time is hsp3cl version 3.7 built for Linux.
It's the CUI version that works with standard I/O only, without a window.

What I Built

I made an API that lets you perform TODO CRUD operations over HTTP. I didn't build a frontend.

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

The configuration is as follows. Three programs are running inside Lambda.

Client


API Gateway (HTTP API)


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

HSP3 is not calling DynamoDB directly. The background on why AWS CLI became the intermediary 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 a method of receiving everything with $default and parsing the path yourself, but I chose to line up 5 routes.
This way, undefined paths return 404 at the API Gateway level. Since Lambda doesn't start, there's no execution charge for those 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 format {"message":"..."}. Validation failures return 400, missing targets return 404, and DynamoDB call failures return 500.
Details such as PUT replacing both attributes together and creating a 409 for ID collisions at creation time are summarized in the repository README.

Lambda Has No Runtime for HSP3

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

That's where custom runtimes come in. For languages not on the list, you can bring your own execution environment.
If you place a program named bootstrap, Lambda starts it as the entry point. Everything beyond that is up to you.

This isn't an unusual approach.
Go and Rust also work this way, with a client that interacts with the Runtime API built on top of this runtime that only provides the OS.

The bootstrap in this case is a shell script.
Its only jobs are handling the exchange with the Runtime API and passing values that HSP3 cannot retrieve 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 execute the HSP3 program with hsp3cl
  4. POST the /tmp/response.json written by HSP3 directly to the Runtime API

Step 2 is needed because hsp3cl has no instruction to read environment variables. The gettime function for getting the time also depends on the timezone, so the current UTC time is also fixed here.
The ID is taken from /dev/urandom as 16 bytes and written out in hexadecimal. The 32-digit id and createdAt values that appear in the operation verification below 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 first, the previous response would be returned as-is.

Note that the entire system is not restarted for each request.
The bootstrap that was 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, Lambda's execution environment has no HOME. It's not included in the list of predefined environment variables.
As a result, a NULL dereference causes SIGSEGV and it exits with exit code 139 without any output. When running locally, the environment provides HOME, so this problem wouldn't be noticed.

HSP3 Cannot Directly Call AWS APIs

When writing Lambda's internals in HSP3, there was a major obstacle right from the start.
hsp3cl cannot directly call AWS APIs.

At minimum, two things are required to call AWS APIs.

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

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

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

The routes for establishing TLS, creating a signature from scratch and sending over raw TCP, and delegating to libcurl are all blocked.

A decision needed to be made here. Implement SHA-256 and HMAC-SHA256 in HSP3 and build the signing from scratch, or delegate that layer to existing tools.
I chose the latter this time.

Delegating Communication to the AWS CLI

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

However, this launch didn't go smoothly.

The exec instruction for running external programs exists in the Linux version and internally calls libc's system(). The execution itself works.
However, the exit code cannot be obtained. Even if a command that exits with exit 3 is passed, stat remains 0.
This means it's impossible to determine whether the AWS CLI succeeded or failed.

So I decided to call libc's system() directly 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.
You can open libc directly and expose system() as an HSP3 instruction.

This allows writing the following from the HSP3 side.

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.
If it crashes with a signal, the lower 7 bits contain the signal number, and right-shifting makes it look like 0 (success), so I treat it as a failure if the lower 7 bits are non-zero (equivalent to a WIFSIGNALED check).
Standard output is redirected to a file and read back with noteload.

Rather than implementing what can't be done, delegate that layer to another tool and take responsibility for the layer above. That's the pragmatic approach I took this time.

HSP3 Implementation

The internals of Lambda 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-style way of writing.

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

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

For character encoding, UTF-8 is handled as a raw byte sequence. Since all JSON structural characters are ASCII, going through them 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 expensive, so this matters.

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

When a condition expression fails, ConditionalCheckFailedException is returned. ID collisions 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 with a straightforward implementation that completes in one call.

The AWS CLI paginates scan by default. DynamoDB's Scan returns at most 1 MB per call.
So the AWS CLI repeatedly calls until LastEvaluatedKey is exhausted behind the scenes and combines 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 the lack of an upper limit on output size.

When I tested locally by putting 40 items of about 40 KB each, totaling about 1.6 MB, the AWS CLI returned all items as a single JSON of 1,605,815 bytes.
The HSP3 side reads the received text into a buffer. Since it was allocated at 1 MB, the JSON was cut off partway through, causing a parse failure and returning 500.

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

I applied two countermeasures. I added --no-paginate to limit to one page, and also expanded the HSP3 buffer to 4 MB.
Since DynamoDB's JSON representation expands beyond the original data due to attribute names and type tags, exactly 1 MB isn't enough.

Re-measuring with the same 40 items, the AWS CLI output fit within 1,084,009 bytes with a LastEvaluatedKey, and 27 items were returned with 200.
The rest are truncated. Pagination retrieval is not implemented.

The internals of a delegated layer are not visible from here.
It's necessary to verify not only whether it gets faster, but also how much it does automatically.

Operation 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 items end-to-end.

Operation Verification

First, let's create an item.

$ 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 wrapped in {"items":[...]} with the same item.

On update, createdAt stays 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 remains 13:29:42, and updatedAt became 13:30:24.
I was able to confirm that the 3 attributes touched by UpdateExpression were not affected beyond those 3.

Deleting returns 204, and fetching afterwards 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 characters also made the round trip without issues.
The route goes through decoding the escaped JSON string within the API Gateway event, re-escaping it for DynamoDB, and retrieving it back out.

Measuring Latency Breakdown

It worked, which is great, but it takes 1.5 seconds to get a response.
It's a clearly noticeable wait — you hit curl and then wait a beat before it comes back.

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

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

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 writing the response.

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

However, that 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 HSP3 reading back and converting the response.
Since I only took the difference, the breakdown within this cannot be determined.

A clue came from measurements taken locally with CPU throttling.
Even in a state where communication couldn't be reached due to missing credentials, a single AWS CLI execution took several seconds (this time also includes the process of looking for credentials). The heavy part is the startup, not the communication.

The 5677 ms cold start was the same story.
Duration and Init Duration are separate items, and Init is also billable (this was already the case for custom runtimes, but from August 2025 all configurations including managed runtimes are included). The actual billed time can be confirmed in the Billed Duration in the REPORT line.
Init is only 157 ms, so runtime initialization is not the dominant factor. The extra 4 seconds are in the first handler invocation.

What makes this slightly tricky is that AWS CLI relaunches a new process for each request.
Since Python and module loading itself occurs every time, that's not the reason for the extra 4 seconds only on the first call.
What's suspicious is the cost of first touching over 200 MB of files from the image. However, this couldn't be pinpointed from this measurement.

If I had gone straight to fixing based on assumptions, I would have spent ages staring at the HSP3 code.
It was fortunate that I happened to have a route available that could be used for isolation.

Incidentally, the sizes of the deliverables are as follows.

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

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

Will Reducing Memory Lower the Cost?

Lambda was allocated 1024 MB of memory. Looking at Max Memory Used, the actual usage was 126 MB.
Only about 10% of the allocation is being used.

Since it's not being used, reducing it to 256 MB should lower the cost. That's what I thought.

The proper approach is to re-measure each memory setting on actual hardware, but let's make an estimate locally first.
The result was the opposite of the hypothesis. There are two reasons.

The first is Lambda's mechanism of allocating CPU proportionally to memory (approximately 1 vCPU at 1769 MB).
A large part of the 1.47 seconds should be time spent on AWS CLI startup and Python module loading. In other words, it's CPU-bound processing.

Let me measure locally by applying only CPU throttling 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 the memory gives 2.8x the time, a quarter gives 11.8x. It's not linear — the disadvantage grows more severe the more you reduce.

Note that this is an alternative measurement using Docker's CFS quota, which is a different mechanism from Lambda's CPU allocation.
Since my local environment is amd64 emulation on Apple Silicon, absolute values are also larger than actual hardware. The ratios are what matter.

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

Furthermore, at 256 MB, there's also concern about cold starts hitting the timeout.
The actual cold start was 5.7 seconds, most of which was the first AWS CLI execution. Since the multiplier only applies to the CPU-determined portion, let's calculate by leaving that extra 4 seconds from before unchanged.
Even applying 11.8x only to the warm 1.47 seconds, the cold start would exceed 20 seconds. That brings into view the function's Timeout: 29 and API Gateway (HTTP API)'s integration timeout upper limit of 30 seconds.

As long as AWS CLI initialization is CPU-bound processing, reducing memory will extend execution time, so GB-seconds won't decrease.
Since no justification was found for lowering to 256 MB, I kept it at 1024 MB. You could say something more definitive by measuring each memory setting 20 times on actual hardware and comparing median warm start, 95th percentile, cold start, and GB-seconds. I didn't go that far this time.

If you really want to make it faster and cheaper, what should be cut is not the memory but the AWS CLI itself in the request path.
If you implement SigV4 in HSP3 and call the curl command from shell_exec, most of the 1.47 seconds would disappear, and then reducing memory would actually make it cheaper.
It would require writing SHA-256 and HMAC-SHA256 in HSP3, so that's another independent study project for another time.

Two Places Where HSP3's Language Spec Tripped Me Up

This is where I spent the most time.

After finishing writing the program and running it, nothing but empty results were returned no matter what was sent.
No errors. The process didn't crash. It was simply unable to find anything.

When this happens, you want to start randomly changing things, but that way you can't tell if anything actually got fixed.
I wrote small programs that isolated the suspicious parts and verified by changing one condition at a time.
There were two causes, and both were things I hadn't anticipated.

First: Variables Inside Modules Were Static Variables

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

This means that when functions are called nested, the callee overwrites the caller's variables.
The variable holding "where we're currently reading" in the JSON parser was corrupted by this, and it was always looking at the wrong position.

Working 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 half 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, return-ing from inside a repeat ~ loop block doesn't release the loop stack.

What makes this tricky is that it doesn't crash on the spot.
After calling a few times, error 30 (Invalid parameter name) or error 9 (Too many nesting) appears at a completely unrelated-looking location.
Looking at the line where the error appeared gives no clue as to the cause.

It was fixed by putting the result into a variable, using break to exit the loop, and then return-ing after the loop.

Beyond these, I also encountered the following:

  • Instructions for #func declared outside a module without global will cause a compile error when called from inside #module. Adding #func global or declaring inside the module makes them callable, 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 in #func causes a runtime error
  • The \n in string literals is expanded to CR+LF. Since \n in JSON is LF, comparisons won't match as-is

Closing Shell Injection Vulnerabilities

system() passes a string to /bin/sh -c.
Concatenating request-derived values into the command string is an immediate shell injection vulnerability.

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

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

Since ; is a command separator, naively assembling the command would execute this as commands.
It's the same structure as SQL injection — a string intended as data becomes an instruction.

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.

The shell isn't only used for DynamoDB calls. API Gateway sometimes sends the body in base64, and the decoding of that 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, the only things that go into the command string are subcommand names and options fixed in the code, and the endpoint URL determined at deploy time.
This URL is enclosed in single quotes. The reasoning isn't "it's safe because it's not request-derived" but rather only values that attackers cannot modify are placed, and they're quoted.

I actually sent the above string and confirmed in tests that the command was not executed and that the value was stored as-is as a string.

However, this test only covers the one string that was sent.
And the policy of "don't put values into command strings" is easy to break if the code is changed. Even leaving warnings and comments, there's no guarantee that someone making changes will read them.

So I added a static check to the build that compares lines involved in assembling strings passed to system() against an allowlist.
If lines assembling commands increase or change, the build stops before compilation.
Since it won't pass without updating the allowlist, whenever this path is touched, a human must once check whether request-derived values are mixed in. It eliminates leaving whether written warnings are followed up to chance.

In addition, the permissions that Lambda's execution role holds for DynamoDB are limited to 5: GetItem / PutItem / UpdateItem / DeleteItem / Scan, with the target restricted to only this table's ARN (plus the standard policy for log output).
Even if unexpected arguments are passed to the AWS CLI, other tables cannot be accessed.

What I Didn't Build This Time

These are parts I intentionally left alone within the scope of this independent study.

  • 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 cannot be directly attached to HTTP APIs, so it would need to be placed on CloudFront in front and attached there. In that case, bypassing by directly calling the execute-api endpoint would also need to be blocked.
    To limit damage in case of runaway, I set Lambda's concurrent executions to 5 and API throttling to 5 per second. However, these are settings to minimize damage, not cost caps
  • Fetching the rest of a list: Since --no-paginate is added to limit Scan to one page, items beyond 1 MB are not returned. Even if LastEvaluatedKey is in the response, it's ignored and truncated.
    Moreover, 200 is returned, so clients have no way to know the list is incomplete. At minimum, whether there are more items should be returned.
    For use cases where item count grows, pagination retrieval or a table design review (GSI and Query) will be needed
  • Strong consistency reads: GET and list use DynamoDB's default eventually consistent reads. Fetching immediately after writing may rarely not show the most recent change.
    Enabling ConsistentRead would provide consistency but increases read cost
  • Update conflict handling: PUT overwrites the existing item as-is, so simultaneous updates to the same item are last-write-wins

Try It Out

The code from this time is in the repository.

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

The HSP3 processor is built from source inside the Dockerfile, so you don't need to install HSP3 locally.
For local testing only, just Docker is needed; for deployment, the AWS CLI is also needed.

To try without an AWS account, run ./local-test.sh.
It sets up DynamoDB Local and automatically verifies 45 items including a full CRUD cycle, validation, Japanese round-trips, and confirmation that shell injection doesn't work. No charges are incurred.

To deploy to AWS, run ./deploy.sh.
Set AWS_REGION and AWS_ACCOUNT_ID and run it, and it will go through from creating the ECR repository to 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. Be sure to delete it after testing.
Deleting 2 stacks will clean everything up. The procedure is in the repository README.
If left running, unknown parties can use it, and charges beyond the free tier will also apply.

Closing

I wrote the backend of a TODO app in HSP3 and got it running on AWS.

Routing, validation, JSON read/write, and assembling DynamoDB requests are all handled by the HSP3 script. What was delegated outside was only TLS and signing.
I had the impression of it being a language for displaying windows, but it's running as a Web API without a window.

Getting it to work involved things like crashing just because HOME wasn't defined, and variables inside modules getting shared and corrupted.
Even so, if you hand off the layers you can't handle to other tools, the layer above that can be written in HSP3.

As a reminder, if you deployed to your own AWS account, please delete it after testing. It remains published without authentication.

I left it all up to AI wondering if it could be done in HSP, and it managed to implement the TODO API with everything written in this blog post and deploy it to AWS.
I was surprised in two senses — "HSP can do this?" and "AI can do this much?"

I'd be happy if you all came away thinking "so HSP can do things like this too."

Share this article

Related articles