Skip to content
Hoody.com

Hoody Exec is the recommended way to run scripts on Hoody. Write a TypeScript or JavaScript function in a file and Hoody Exec serves it as an HTTP endpoint, handling routing, JSON serialization, dependency installation, and production-grade execution. You do not install Express or Fastify, configure a web server, or maintain a package.json. This is “everything is HTTP” in practice: the file you write is the API.


Each script runs in one of two execution modes, and that choice determines most of how it behaves: whether context state persists, whether WebSocket is available, and when its context is built. Neither mode is a security boundary. Both modes run with the Exec service’s own authority: scripts can use the filesystem, spawn subprocesses and reach the network, so both need code you trust. Give mutually untrusted workloads separate containers.

FeatureWorker modeServerless mode
StatePersistent shared objectSame per-instance shared object; only context-local state resets
WebSocketSupportedNot available for script WebSockets (remote channels excepted)
ConcurrencyUnlimited by default (@concurrent caps it)Unlimited by default (@concurrent caps it)
StartupOnce (fast subsequent requests)Per request (slight overhead)
MemoryHigher (context stays alive)Lower (context per request)
Use caseReal-time, stateful APIsWebhooks, one-off tasks

Worker mode (// @mode worker) keeps one JavaScript context alive per instance (one exec ID), shared by every worker script in that instance: context state is kept across requests, WebSocket is supported, and later requests reuse the cached context. The first request, and requests after a restart or cache eviction, build a new context.

Serverless mode (// @mode serverless, also the default when the comment is omitted) builds a fresh JavaScript context for every request, in the style of AWS Lambda or Vercel Functions: context-local state resets and configurable concurrency. Functions a script registers for remote messages and calls are the exception: they keep the variables of the run that registered them.

See Execution Modes for the full comparison with worked examples.


  • Files as endpoints: creating a file creates an HTTP endpoint, with no server configuration.
  • Two modes: worker (one long-lived context) or serverless (a context per request).
  • Magic comments: configure scripts with // @mode worker, // @cors *, // @timeout 5000.
  • File-based routing: api/users/[id].ts serves /api/users/123 with dynamic parameters.
  • Bun runtime: scripts run on Bun, a modern JavaScript runtime faster than Node.js.
  • AI helpers: ai, openai, model, generateText, and more are injected automatically. They are on by default; set // @ai false to disable them.
  • Hoody SDK client: hoody is injected already pointed at the script’s own container, so await hoody.files.readText(path) or await hoody.terminal.run(cmd) works with no login or setup. See Hoody SDK client.
  • Dependency auto-install: require('axios') installs the package automatically — maintaining a package.json is optional.
  • Templates: scaffold scripts from built-in or custom templates.
  • Code validation: TypeScript syntax checking, syntax validation, and dependency analysis. With typecheck: true, Hoody SDK calls are also checked against the SDK version installed, and GET /api/v1/exec/sdk-types looks up exact signatures, so an agent finds a wrong method or argument before deploying. See Check SDK calls before they run.
  • Shared state: an in-memory KV store shared across requests, per instance (hostname and exec ID), in both modes.
  • WebSocket support: real-time bidirectional communication (script WebSockets need worker mode; remote channels also work in serverless mode).
  • Remote messages and events: a running script publishes events with remote.emit() to clients listening over SSE or WebSocket on its own URL, and answers messages and calls from them. Off by default; see Remote Messages and Events.
  • Monitoring: performance metrics, log streaming, and cache stats.

Create scripts/default/1/api/hello.ts:

// @mode serverless
// @cors reflective
// @timeout 5000
return {
message: 'Hello from Hoody Exec!',
timestamp: new Date().toISOString(),
method: metadata.method,
ip: metadata.clientIp
};

The script is now live at:

https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/hello

It needs no imports, exports, or configuration. The parameters (metadata, req, res, shared) are injected automatically.


Each Hoody Exec instance is reachable at its own hostname:

{projectId}-{containerId}-exec-{execId}.{server}.containers.hoody.com
ComponentDescriptionExample
projectIdYour project IDabc123
containerIdContainer IDdef456
execIdExec instance identifier1, 2, test
serverServer locationnode-us, node-eu

The exec ID determines which script directory is served. The full root on the container filesystem is /hoody/storage/hoody-exec/scripts/, and each instance gets its own folder beneath it, which is searched first:

  • exec-1 serves scripts from /hoody/storage/hoody-exec/scripts/default/1/
  • exec-2 serves scripts from /hoody/storage/hoody-exec/scripts/default/2/
  • exec-test serves scripts from /hoody/storage/hoody-exec/scripts/default/test/

The default segment is the subdomain folder, and public exec URLs always serve from default. A hostname with an extra name in front, such as myapp.{projectId}-{containerId}-exec-1.{server}.containers.hoody.com, does not reach your container.

You can run multiple exec instances on a single container, each with its own folder and its own shared state. A request is looked up in these folders in order, and the first one with a matching script wins (a script that is a symbolic link is only tried after every regular-file match): scripts/default/<execId>/, then scripts/default/, then scripts/<execId>/, then the scripts root. Scripts placed in default/ or directly in the scripts root are therefore served by every instance. To keep one instance confined to its own folder, add an empty file named .exclusive to it (for example scripts/default/1/.exclusive); that turns the fallbacks off for that instance, and also stops it running any script reached through a symbolic link. See Routing for the full lookup order. Subdomains resolve automatically, so a new exec ID is routable the moment you create it. An exec ID is lowercase letters and digits only, such as 1, test or demoapi: a folder named with a hyphen, underscore or dot (demo-api) is still listed below but is never served.

Use the exec list endpoint to see which exec instances are available in the default namespace:

Terminal window
# List all exec instance IDs
hoody exec namespaces list -c CONTAINER_ID -o json

Example response:

{
"execIds": [
{ "id": "1", "type": "custom", "files": 4 },
{ "id": "2", "type": "custom", "files": 1 }
],
"total": 2,
"summary": { "sdk": 0, "custom": 2 }
}

To list scripts for a specific exec instance:

Terminal window
# List system scripts in the current exec instance
hoody exec scripts list -c CONTAINER_ID -o json

Every script is an HTTP endpoint, so any script can call any other script with fetch():

scripts/default/1/api/aggregate.ts
// @mode serverless
// Call other exec scripts by their URL path
const news = await fetch("/api/hackernews");
const weather = await fetch("/api/weather");
const [newsData, weatherData] = await Promise.all([
news.json(),
weather.json()
]);
return { news: newsData, weather: weatherData };

Relative paths like /api/hackernews resolve to the same exec instance. They need a request to resolve against, so a script run by @schedule must use full URLs. To call scripts on a different instance or container, use the full hostname:

// Call a script on exec instance 2
const res = await fetch("https://PROJECT-CONTAINER-exec-2.SERVER.containers.hoody.com/api/users");
// Call a script on a different container
const res2 = await fetch("https://PROJECT-OTHER_CONTAINER-exec-1.SERVER.containers.hoody.com/reports/daily");

Get your first script running in three steps:

1. Write a script file:

scripts/default/1/hello.ts
// @mode serverless
return { message: 'Hello, world!', time: Date.now() };

Or create it programmatically:

Terminal window
# Write a script to the exec instance
hoody exec scripts write -c CONTAINER_ID --path "hello.ts" \
--content "// @mode serverless
return { message: 'Hello, world!' };" \
--create-dirs

2. Execute the script by requesting its URL:

Terminal window
curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/hello"

3. Verify the response and logs (the file version above also returns a time field):

{ "message": "Hello, world!" }

Stream logs in real time to debug:

Terminal window
# Stream logs from the exec instance
hoody exec logs stream -c CONTAINER_ID --file "access/access.log"

All endpoints are relative to your exec instance:

https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com

Every /api/v1/exec/ endpoint shares one rate limit: 100 requests per 60 seconds per client. Past it the answer is 429 with retryAfter, the seconds until the window ends, in the JSON body. Requests to your scripts’ own routes are not counted.

Core execution:

Script management:

Templates:

Validation:

Dependencies:

Routing:

Package management:

Magic comments API:

State and cache:

Logs:

Monitoring:

Schedules (cron):

System:

User OpenAPI:

SDK management:

Custom templates:

Bundled dependencies:


Write functions in files instead of setting up Express or Fastify; each file is an HTTP endpoint as soon as it exists. Use worker mode for performance, serverless for sporadic traffic.

WebSocket chat servers, live dashboards, SSE streams, and collaborative tools: worker mode’s long-lived context handles connections efficiently with shared state.

Stripe, GitHub, and Slack webhooks: a fresh context per request resets context-local state (modules you require or import can still keep state between requests, so keep delivery-specific data out of them), and @concurrent false gives serial processing for consistency.

Intercept and control AI requests through the MITM proxy: add safety checks, modify prompts, and track usage. See Hoody AI Intercept & Control.

Register hoody-exec scripts as MITM handlers for any container service (terminal, files, curl, …) directly in your proxy permissions document. The Hoody Proxy dispatches matching traffic through your hook with the real client IP preserved. See Proxy Hooks.

Admin dashboards, data migration scripts, and reporting endpoints: choose worker mode for speed with caching, or serverless when a fresh context per request suits the traffic.

Track user sessions, implement rate limiting, and maintain connection pools: shared state persists across requests with zero overhead.

Iterate quickly with magic comments, test ideas without deployment complexity, and have AI generate boilerplate. Idea to API in seconds.


Use worker mode when you need state or WebSocket, or when you serve high request volume with caching. Use serverless mode for webhooks and sporadic traffic, where building a context per request costs less than keeping one alive.

Declare @mode first. Set a @timeout suited to the endpoint (it answers 504 when nothing has been sent by the deadline; it does not stop a script that has already started or cut off a response that has already started, such as a stream; the deadline also counts time spent loading the script and waiting for a @concurrent slot, and a script that has not started by then never runs (unless pre.ts or pre.js already sent headers, left the response open and returned nothing: then the main script can still start after the deadline). Requests from a proxy hook use the hook’s own deadline instead, which can cut off a response already under way), use @cors reflective during development, and enable a logging level suited to your debugging. See the Magic Comments reference.

Never run untrusted code in either mode: both run with the Exec service’s own authority over the filesystem, subprocesses and network, and both see the same per-instance shared object. Give mutually untrusted workloads separate containers. Put sensitive operations behind authentication, use the container firewall in production, and validate all user input.

Treat the shared object as an in-memory cache only. Clean up old state periodically to prevent memory leaks, expect state to be lost on restart, and use SQLite for data that must survive restarts.

Use @concurrent 5 to limit parallel executions and prevent overload, or @concurrent false for serial processing of webhooks, and watch request activity through the monitoring API. @concurrent applies in both worker and serverless mode, to cron fires, and to remote send and call. Messages to an ordinary script WebSocket are not held back by it.

Let auto-install handle common packages without configuration. Pin versions in a package.json when production stability calls for it, test dependencies before deploying, and watch node_modules size growth.

Use worker mode for frequently called endpoints, since later requests reuse the cached context. Watch request latency through the monitoring endpoints, keep scripts small and focused (under 200 lines), and set @timeout so a request that has received nothing by the deadline gets a 504 (a script that has already started keeps running, one still waiting to start never runs (unless pre.ts or pre.js already sent headers, left the response open and returned nothing: then the main script can still start after the deadline), and a response already under way, such as a stream, is not cut off).


When should I use worker vs serverless mode?

Section titled “When should I use worker vs serverless mode?”

Use worker mode for WebSocket, stateful apps, high-traffic APIs, session management, and rate limiting. Use serverless mode for webhooks, one-off tasks, sporadic traffic, and operations that keep no context state. See Execution Modes for the full comparison.

Not for ordinary script WebSockets: they need persistent connection handling in a long-lived context, so only worker mode supports them. Remote-channel WebSockets (see Remote Messages and Events) also work with serverless scripts.

It is lost completely: shared is in-memory only. Use SQLite (the hoody-sqlite service) or an external database for data that must survive restarts.

By default a worker handles unlimited concurrent requests in its shared context. You can cap parallelism with @concurrent N, serialize with @concurrent false (the directive applies in worker mode as well as serverless), or manage concurrency yourself with semaphores or queues for finer control.

Yes. Each script declares its own mode independently, so a worker script at /api/ws.ts can coexist with a serverless script at /webhooks/stripe.ts.

They are parsed at script load time and configure script behavior without code changes. They take precedence over defaults, and the validation API can parse and check them; change a comment and the next request uses it. @schedule, @schedule-timeout and the @timeout a scheduled run falls back to are the exception when you edit the file directly: call POST /api/v1/exec/schedules/reload to apply them (the scripts and magic-comments APIs do this for you). See Magic Comments.

Yes. Bun transpiles .ts files automatically, with no configuration needed. The validation endpoints check TypeScript syntax and transpilation. Use a separate TypeScript checker for type errors.

Hoody Exec detects packages that your script names with a plain string in require() or import, checks whether each is installed, and runs npm install automatically. A package named by a computed value, such as require(name), is not detected, so install it first with the package-management endpoints. It caches the install status and uses the latest versions unless you pin them in a package.json. An installed copy older than the version range Hoody Exec lists for one of its always-available packages is reinstalled on use: without a pin that brings the latest version, but a version pinned in the scripts folder’s package.json survives the reinstall, so the older copy stays (a successful reinstall is not repeated until the server restarts). hoody-sdk is installed the same way, and Hoody Exec also keeps it current: it downloads a newer npm release in the background and switches to it when Hoody Exec restarts, unless the scripts folder’s package.json pins the SDK to an exact version (a range keeps updates within that range). A version written into the specifier, such as require('lodash@4.17.21'), fails with ERR_VERSIONED_SPECIFIER; pin it in package.json instead. See Writing Scripts.

No. Hoody Exec is the web server: you write functions in files, and the file system is the routing configuration. There is no Express, Koa, or server setup involved.


Cause: No script file matches the URL path, or the matching script has @enabled false (a disabled script answers 404 and no other script is tried). Solution: Check that the file exists at /hoody/storage/hoody-exec/scripts/default/1/path/to/script.ts, verify the filename matches the route exactly, and test with the route validation API.

Cause: The comments are not at the top of the file, or a comment name or value is invalid. (A syntax error in the code stops the script from running, but does not stop the comments being read.) Solution: Place magic comments before any code, including imports, and within the first 30 lines of the file (directives after line 30 are ignored). Use the validation endpoint to parse them, check the exact syntax (a space after //), and verify the comment name spelling. See Magic Comments.

Cause: The container restarted, the state was cleared, or the service evicted that hostname’s idle state. Solution: shared is in-memory in both modes, so any of those empties it for new requests (a WebSocket connection that is still open keeps the old object until it closes). Use the SQLite service for persistence. See Execution Modes.

Cause: A magic comment is missing, or the script is in the wrong mode. Solution: The script needs both // @mode worker and // @websocket. Serverless mode cannot serve ordinary script WebSocket connections.

Cause: Reflective CORS is on by default, so a missing comment is not the cause. A script CORS comment may restrict the origin, or the response or preflight headers may differ from what you expect. Solution: Check the response and preflight headers in the browser. If the script restricts CORS, use // @cors reflective (// @cors * is an alias for it), or pin a specific origin for production (// @cors https://app.com).

Cause: A long-running operation exceeds the timeout. Solution: The default limit is one hour (runs started by @schedule use @schedule-timeout if set, otherwise @timeout, otherwise 30 seconds). Raise it with // @timeout, for example // @timeout 2h, or use // @timeout 0 for no limit (risky). Synchronous code that blocks the server for longer than its watchdog limit (30 seconds by default) gets the server restarted, whatever @timeout says. If you set a lower value earlier, such as // @timeout 60000, that is the limit you are hitting. Optimize slow operations and consider async patterns.

Concurrent request limit reached (serverless)

Section titled “Concurrent request limit reached (serverless)”

Cause: The @concurrent limit is reached and requests are queuing. Solution: Increase the limit (// @concurrent 10), optimize script performance, consider worker mode for high traffic, and watch request activity through the monitoring API.

Cause: The installation failed, or a network issue interrupted it. Solution: Check network connectivity, verify the module exists on npm, read the logs for install errors, check the module name spelling, and install manually if needed.