Skip to content
Hoody.com

A Hoody Exec script needs no boilerplate, exports, or imports: write your logic and return a value. Hoody Exec turns the return into the HTTP response and injects every parameter you need into the script context.


A Hoody Exec script is a plain TypeScript or JavaScript file. Magic comments at the top configure behavior; the rest is your code. Return a value and it becomes the HTTP response.

scripts/default/1/api/users/[id].ts
// @mode worker
// @cors reflective
// @timeout 5000
// All parameters are available automatically:
// req, res, metadata, shared, console, require, ws
// (mainResult is additionally available only in post.ts or post.js middleware)
const { id } = metadata.parameters; // Dynamic route param
const user = { id, name: 'Example' }; // Look the user up here
// Return value auto-formatted as JSON
return { user };

Key points:

  • Parameters are injected automatically, so an export is optional. Direct returns, CommonJS handlers, default-exported handlers, HTTP-method exports, and Express apps are all supported. An HTTP-method export such as export async function POST(request, context) gets a Web Request instead of req: read the body with await request.json() and route parameters from context.params.
  • You do not need module.exports: write code at the top level, or use Pattern 2 below.
  • Built-ins need no imports: crypto, fs, path, $, and Database are pre-injected.
  • Magic comments go at the very top, before any code.
  • Return a value to send it as the response, or use res for full control.

Every script receives these parameters automatically, with no imports or configuration:

// req - Incoming HTTP request
req.url // '/api/users/123'
req.method // 'GET', 'POST', etc.
req.headers // { 'authorization': 'Bearer ...', ... }
req.body // Parsed JSON body (if Content-Type: application/json; never read for GET and HEAD; the body is read before your script runs unless // @rawBody;
// a complete body over 10 MiB by default gets 413 and malformed JSON gets 400, before any script runs)
// res - HTTP response (for full control)
res.statusCode = 200 // Set status and headers first
res.setHeader('Content-Type', 'application/json')
res.setHeader('X-Custom', 'value')
res.end(JSON.stringify({ success: true })) // then send the body
// metadata - Request context and routing info
metadata.executionId // Unique execution ID
metadata.parameters // Merged dynamic-route params + query string, e.g. { id: '123', search: 'term' }
metadata.clientIp // Real client IP (see below)
metadata.path // '/api/users/123'
metadata.method // 'GET', 'POST', etc.
metadata.url // Full URL
metadata.query // Alias of metadata.parameters: the same merged object (either name works)
// Exception: in the WEBSOCKET_INIT and REMOTE_INIT runs, parameters holds only
// the route params and query only the query string. In runs started by
// @schedule, both start as separate empty objects
// shared - Persistent state per deployment (script hostname plus exec ID), in both modes; scripts in the same deployment share it
shared.cache ??= new Map() // Persists across requests
shared.requestCount ??= 0 // Persists too, in both modes. Memory only: lost on a restart, a state
// clear, or when the service evicts an idle instance's state
// console - Logger
console.log('message')
console.info('info')
console.debug('debug')
console.error('error')
// require - Module loader (missing packages named in the script are installed before it runs)
const axios = require('axios') // Installed before the script runs if not present
const lodash = require('lodash') // Installed before the script runs if not present
// env - Frozen environment snapshot for this invocation
env.DATABASE_URL // From the container environment or a .env companion file
// hoody - Hoody SDK client, already set up for this container (see below)
await hoody.files.readText('/etc/hostname')
await hoody.terminal.run('uname -a')

Accepting WebSocket connections through ws needs // @websocket and worker mode (remote sockets are separate and are not part of ws). In a script without @websocket, in either mode, ws is still there, but ws.connections and ws.broadcast cover every open WebSocket of the instance. A serverless script with @websocket has no connections. Provides full control over WebSocket connections:

// Event handlers: direct assignment pattern
ws.open = (socket, req) => { ... }
ws.message = (socket, data) => { ... }
ws.close = (socket, code, reason) => { ... }
ws.error = (socket, error) => { ... }
// Or the event emitter pattern
ws.on('open', (socket, req) => { ... })
ws.on('message', (socket, data) => { ... })
ws.on('close', (socket, code, reason) => { ... })
ws.on('error', (socket, error) => { ... })
// Connection management
ws.connections // Set of this script's active connections (per dynamic route parameters and script version:
// after an edit or a cache clear, connections opened before and after the next upgrade are separate sets)
ws.broadcast(data) // Send data to all connections in ws.connections
ws.broadcast(data, excludeSocket) // Send to all except one client
// Socket data: available on each socket instance
socket.data.ip // Client IP address
socket.data.url // Request URL
socket.data.headers // Request headers
socket.data.parameters // Query-string values plus dynamic route parameters (route parameters win)
socket.data.executionId // Unique execution ID for this connection
// mainResult - only available in post.ts or post.js middleware
// Contains the return value of the main script that just executed.
// If pre.ts returned a value and skipped the main script, it holds that value instead.
// If pre.ts ended the response itself without returning anything, it is undefined.
// Use it to wrap, transform, or log responses
// post.ts example:
return {
data: mainResult,
timestamp: Date.now(),
requestId: metadata.executionId
};
// Available by default (set // @ai false to disable); no imports needed.
// Not available in pre.ts and post.ts
ai // Helper namespace with three methods:
// ai.generate(opts): generate a text completion
// ai.stream(opts): stream text chunks
// ai.object(opts): generate structured JSON against a schema
openai // Vercel AI SDK provider (made with createOpenAI, already configured); call openai(modelId) for a model
// (not the official OpenAI client; for that, require('openai'))
model // Pre-configured Vercel AI SDK model (from @ai-model or default)
generateText // Vercel AI SDK: generate text completions
streamText // Vercel AI SDK: stream text responses
generateObject // Vercel AI SDK: generate structured JSON objects

See AI-Powered Scripts for full usage examples and model configuration.


env is a frozen object holding the service’s own environment plus any values loaded from .env companion files beside the script. pre.ts and post.ts get the main script’s values; a pre.env or post.env file is not loaded for them. It is built fresh for each invocation, so one script’s values never leak into another sharing the same worker. A WebSocket or remote handler keeps the env of the run that registered it, so after editing a .env file by hand those handlers still see the old values until the script runs its setup again. Because it is frozen, assigning to it does nothing (in strict-mode code it throws); use shared for per-instance state you want to change at runtime.

The same values are also reachable through the global environment object. Changes made there — setting a value, removing one, defining one — apply for the rest of that request and are then discarded: they never reach the next request, another script running alongside yours, or the service’s own environment. Two exceptions: in worker mode, the handlers of one WebSocket connection share one view of it, so a change made while handling one message is still there for the next message on that connection; and in serverless mode, functions a script registers for remote messages and calls keep the context of the run that registered them, so changes they make to this object carry over from one remote operation to the next. Treat it as read-only and keep anything you want to survive in shared.

In worker mode, two operations on that object are refused outright rather than silently doing nothing: freezing or sealing it, and defining a property that is explicitly marked non-configurable. Both raise an error for the script that attempted them and change nothing, for anyone. In serverless mode the object belongs to that one request’s context, so both succeed but affect only that context (which registered remote functions keep, as above).

Two file names are read, both inside the scripts directory:

  • <script>.env, named after the script (api/users.env for api/users.ts). Loaded last, so it wins ties.
  • _default.env, in the script’s own directory and in every parent directory up to the scripts root. Applied outermost first.

A _default.env at the root sets values for every script, a _default.env in api/ narrows them for that folder, and api/users.env overrides them for one script.

These keys are ignored in .env files, with a warning: PATH, HOME, USER, SHELL, TERM, NODE_OPTIONS, NODE_PATH, LD_PRELOAD, LD_LIBRARY_PATH, SCRIPT_DIR, LOGS_DIR, HOODY_MODULE_RESOLUTION_MODE, PORT and HOSTNAME. A file longer than 65,536 characters is ignored entirely; within a file, a key longer than 256 characters or a value longer than 32,768 characters is skipped, and only the first 256 entries are read.

api/_default.env
API_BASE=https://api.example.com
TIMEOUT_MS=5000
api/users.ts
const res = await fetch(`${env.API_BASE}/users`);
return res.json();

A helper file the script loads with require or import sees the same values: process.env inside ./lib/rates.js is the calling script’s, .env companion values included. Three kinds of file are loaded outside your deployment and see only the service’s own environment: ES module helpers that import each other in a cycle, .jsx / .tsx files, and files outside the scripts directory. Pass values to those as arguments:

// quote.js (./lib/rates.js exports rate(key, from, to))
const { rate } = await import('./lib/rates.js');
return { eur: await rate(env.RATES_KEY, 'USD', 'EUR') };

Every script gets hoody, a Hoody SDK (hoody-sdk) client already pointed at the container the script runs in. No import, no login, no container lookup: call the kits directly.

api/status.js
const hostname = await hoody.files.readText('/etc/hostname');
const { stdout } = await hoody.terminal.run('uptime -p'); // also exitCode, stderr
return { hostname: hostname.trim(), uptime: stdout.trim() };
api/count.js
const { data } = await hoody.sqlite.sql.queryReadOnly({
db: 'app',
sql: btoa('select count(*) as n from users'),
});
return data;

Kit calls (hoody.files, hoody.terminal, hoody.sqlite, and the other kits) go to the services of the script’s own container and need no token: the container already identifies the script. Calls to the Hoody API itself (hoody.api.*, such as listing your containers) act on your account, so they need your token.

hoody is built from the script’s environment, the same values env holds:

VariableUsed for
HOODY_TOKEN (or HOODY_API_TOKEN)Your Hoody token, sent only on hoody.api.* calls
HOODY_BASE_URL (or HOODY_API_URL)The Hoody API address. Without it, the address of the platform the container runs on.
HOODY_REALMThe realm, when you use one
HOODY_CONTAINER (or HOODY_CONTAINER_ID)The container. The container sets it to itself; leave it as it is.

These are the names the hoody CLI reads. To use other values for one script or one folder, set them in a .env companion file (api/report.env, or _default.env for a whole folder). That script then gets a client built from those values, and every other script keeps its own. Each deployment (hostname and exec ID) has its own client, so values set for one never reach another.

The SDK is loaded the first time a script touches hoody, so a script that never uses it pays nothing. If the SDK is not installed yet, it is installed before the script runs; a script that reaches hoody while that install is still running gets an error with code HOODY_SDK_INSTALLING, and a retry a moment later succeeds.

What can fail, and what the error says:

  • No token: a hoody.api.* call with no HOODY_TOKEN fails before anything is sent: a 401 error with code HOODY_CLIENT_NOT_CONFIGURED, naming the variables to set. Kit calls still work.
  • Another container selected: hoody always talks to its own container. If HOODY_CONTAINER names a different one, reading a kit fails with an error pointing you to withContainer (below).
  • An API address that covers the kits: if HOODY_BASE_URL is a parent of the kits’ address (for example https://hoody.com, while kits live under ….containers.hoody.com), your token would reach the kits too. The first use of hoody then fails with HOODY_CLIENT_NOT_CONFIGURED, naming HOODY_BASE_URL. Use the API host itself, such as https://api.hoody.com.

Your token stays with the Hoody API: it is never sent to a kit, and a redirect to another address is not followed with it. Changing HOODY_BASE_URL moves only the account calls; kits stay at the container’s own address.

hoody is available wherever your script code runs: the script itself, helper files it loads, pre / post middleware, WebSocket and remote handlers, and @schedule runs.

A script that declares its own hoody keeps it. To reach another of your containers, scope a client to it:

const other = await hoody.withContainer(env.OTHER_CONTAINER_ID); // a lookup: needs HOODY_TOKEN
return await other.files.readText('/srv/report.txt');

If that container’s kits need their own credentials, pass them: await hoody.withContainer(id, { kitAuth }). Kit credentials are never taken from environment variables. A client keeps the kitAuth it was given, and so does every client derived from it, so pass a fresh kitAuth each time you switch container, or derive from hoody, which has none.

For a different account, build a client yourself with new HoodyClient({ token }) from hoody-sdk.


Scripts are transpiled, not type-checked, so a call to a method that does not exist (hoody.files.lst), a wrong argument object, or a forgotten await deploys fine and fails only when a request reaches it. For an AI agent writing scripts, that means guessing method names from memory and learning the truth from a 500.

Two tools close that gap. Both read the Hoody SDK copy installed in your scripts directory, the one your scripts load, so they always describe the version that will run. When the SDK updates, or you pin another version, both follow it with nothing to regenerate. Every answer carries sdkVersion.

  • GET /api/v1/exec/sdk-types looks up methods: their call path, full signature, description and an example.
  • typecheck: true on validate/typescript, validate/script and scripts/write runs the TypeScript compiler over your script against the SDK’s declarations and the variables every script gets (hoody, req, res, env, shared, …). Wrong methods, arguments and types come back as errors on your own lines.

The workflow for an agent: look up the methods you need, write the script with typecheck: true, and fix whatever comes back before the endpoint is ever called.

$EXEC below is your Hoody Exec address. See URL structure for where the project ID, container ID and server name come from.

Terminal window
EXEC="https://$PROJECT_ID-$CONTAINER_ID-exec-1.$SERVER_NAME.containers.hoody.com"
# Every kit on the client, with its number of methods
curl "$EXEC/api/v1/exec/sdk-types"
# Methods of one kit whose path, name or description contains every word of q
curl "$EXEC/api/v1/exec/sdk-types?kit=files&q=readText"
{
"sdkVersion": "1.0.0-beta.16",
"total": 1,
"truncated": false,
"methods": [
{
"kit": "files",
"path": "hoody.files",
"method": "readText",
"signature": "readText(path: string, options?: FilesReadOptions, templateVars?: FilesReadTarget): Promise<string>",
"summary": "Read a file as text (UTF-8). Resolves to the string itself, no { statusCode, message, data } envelope. A missing file rejects with the ApiError of get (err.status === 404).",
"example": "await hoody.files.readText('…')",
"file": "dist-ts/lib/files-service-extensions.d.ts",
"params": [
{ "name": "path", "type": "string", "optional": false },
{ "name": "options", "type": "FilesReadOptions", "optional": true,
"fields": [{ "name": "backend", "type": "string", "optional": true }] }
]
}
]
}

params lists each parameter (name, type, optional) and, for an object parameter, its fields one level deep (the answers here are trimmed). When an object accepts one of several shapes, anyOf lists the required fields of each, and a field only some shapes need carries requiredIn: "some". Logging in takes a username or an email, plus the password:

{
"method": "login",
"path": "hoody.api.auth",
"example": "await hoody.api.auth.login({ username: '…', password: '…' })",
"params": [
{ "name": "data", "type": "ApiAuthLoginRequest", "optional": false,
"anyOf": [["username", "password"], ["email", "password"]],
"fields": [
{ "name": "password", "type": "string", "optional": false },
{ "name": "username", "type": "string", "optional": true, "requiredIn": "some" },
{ "name": "email", "type": "string", "optional": true, "requiredIn": "some" }
] }
]
}
  • kit: a property of the client, such as files, sqlite, terminal, browser, api or exec. Use client for the client’s own methods, such as withContainer. An unknown kit answers 404 UNKNOWN_KIT, with the valid names in details.kits.
  • q: optional; every word must match.
  • limit: 1 to 100, default 20. total counts every match, and truncated: true means there are more.
  • Several call forms: a method that has them appears once per form.
  • example: a call that fills in every required argument with '…' placeholders, or the SDK’s own example when it has one. When no safe call can be built from the types, the example is (…): read params and summary instead.
  • incomplete: true on a parameter means its type was too complex to list in full; read the signature, or use raw=true.
  • raw=true: returns the SDK’s declaration files that define the matches, up to 256 KB. Use it when a signature names a type, such as FilesReadOptions, and you need its fields.
  • No SDK installed yet: the answer is 404 SDK_NOT_INSTALLED. Hoody Exec installs the SDK when it starts, so this passes on its own.
Terminal window
curl -X POST "$EXEC/api/v1/exec/validate/typescript" \
-H "Content-Type: application/json" \
-d '{"code":"return await hoody.files.lst(\"/\");","typecheck":true}'
{
"valid": false,
"typeChecked": true,
"sdkVersion": "1.0.0-beta.16",
"diagnostics": [
{
"line": 1,
"column": 26,
"code": 2551,
"message": "Property 'lst' does not exist on type 'FilesService & …'. Did you mean 'list'?",
"source": "return await hoody.files.lst(\"/\");",
"hint": {
"access": "hoody.files.lst",
"candidates": [
{ "path": "hoody.files", "method": "list", "signature": "list(path: string, …)", "summary": "List a directory as JSON …" },
{ "path": "hoody.files", "method": "exists", "signature": "exists(path: string, …): Promise<boolean>", "summary": "Whether a file or directory exists at `path` …" },
{ "path": "hoody.files", "method": "get", "signature": "get(path: string, …)", "summary": "List directory or download file" }
]
}
}
]
}

Each error carries what you need to fix it without another lookup:

  • source: the line the error is on, as you sent it.
  • hint, on an error in a hoody call (at most 10 per check):
    • access: the call path as you wrote it.
    • candidates: for a method that does not exist, the 3 closest real methods with their signatures.
    • method: for a method that exists but was called wrongly (missing or extra arguments, a wrong field), its full sdk-types entry, with signature, params and example.
    • note: for a forgotten await. For example const other = hoody.withContainer('abc'); other.files… gets “hoody.withContainer(…) returns a Promise: await it before using the client”.

For example, hoody.sqlite.sql.queryReadOnly({ db: "app" }) without sql fails with code 2741 (“Property ‘sql’ is missing…”), and its hint.method carries the example await hoody.sqlite.sql.queryReadOnly({ db: '…', sql: '…' }).

To check and save in one step, pass typecheck: true to scripts/write. The script text goes in content:

Terminal window
curl -X POST "$EXEC/api/v1/exec/scripts/write" \
-H "Content-Type: application/json" \
-d '{"path":"api/hostname.js","content":"const h = await hoody.files.readText(\"/etc/hostname\");\nreturn { hostname: h.trim() };","typecheck":true}'

When the script passes, it is written and the answer carries typeChecked: true and a typecheck object (valid, diagnostics, sdkVersion). The script is then live at $EXEC/api/hostname. If the script const h = await hoody.files.readTxt(…) has a typo, the answer is 400 and nothing is written, so an existing file at that path stays as it was (trimmed; details.validation.errors also lists it with type: "typecheck"):

{
"error": "Script validation failed",
"details": {
"validation": {
"typecheck": {
"typeChecked": true,
"valid": false,
"sdkVersion": "1.0.0-beta.16",
"diagnostics": [
{
"line": 1,
"column": 29,
"code": 2551,
"message": "Property 'readTxt' does not exist on type '…'. Did you mean 'readText'?",
"source": "const h = await hoody.files.readTxt(\"/etc/hostname\");",
"hint": {
"access": "hoody.files.readTxt",
"candidates": [
{ "path": "hoody.files", "method": "readText", "signature": "readText(path: string, options?: FilesReadOptions, templateVars?: FilesReadTarget): Promise<string>", "summary": "Read a file as text (UTF-8) …" }
]
}
}
]
}
}
}
}

Fix the lines named in diagnostics, using each hint, and send the write again. (Trimmed: the real candidates list holds up to 3 methods.)

  • The three endpoints:
    • validate/typescript always answers 200; read valid and diagnostics.
    • validate/script puts the result under results.typecheck.
    • scripts/write with typecheck: true refuses a script with type errors: 400, with the errors in details.validation.typecheck.diagnostics. Nothing is written.
  • Line numbers: line and column point into the code you sent. At most 50 errors are returned.
  • What it checks: the check is loose, aimed at mistakes rather than style. There is no strict mode. A package without type declarations is not an error: its values are treated as any.
  • Script variables: they are typed as Hoody Exec binds them. hoody is the SDK client; crypto, WebSocket and Buffer are the Node versions.
  • Helper files: values from your own helper files (./lib/x) are not checked; they are treated as any.
  • Speed: a check takes a few hundred milliseconds.
  • First use: the first check in a container installs the TypeScript compiler (about 24 MB) into the scripts directory.
  • When a check cannot run: this happens when npm is unreachable, the check times out after 30 seconds, or too many checks are waiting. The answer then keeps typeChecked: false and says why in typecheckError (typecheck.error on validate/script and scripts/write). Nothing is blocked: scripts/write still writes.

Without typecheck, these endpoints behave as before: a transpile check only, answering typeChecked: false.


The res object is enhanced with Express-like convenience methods:

res.json({ data: 'value' }) // Send JSON response
res.send('text') // Send text response
res.html('<h1>Hello</h1>') // Send HTML response
res.redirect('/new-path') // HTTP redirect
res.stream('text/event-stream') // Set streaming headers, then res.write()/res.end() (default text/plain)
res.sse(async (send) => { ... }) // Server-Sent Events with backpressure handling
res.status(404) // Set status code (chainable)

Example with chaining:

// Return a 201 JSON response
res.status(201).json({ created: true, id: 'abc123' });

Server-Sent Events with res.sse:

res.sse writes the event-stream headers, hands your callback a send function and a close function, and closes the connection when the callback returns. For a HEAD request, or a 204 or 304 status, it sends the headers and ends at once without calling your callback. send accepts a string or an object of the form { data, event?, id?, retry? } (a data that is not a string is serialized to JSON), and returns a promise resolving to false once the client has gone, so a slow or disconnected reader cannot make the script write into a dead socket.

// @mode worker
const rows = [{ id: 1 }, { id: 2 }];
await res.sse(async (send) => {
for (const row of rows) {
const alive = await send({ data: row, event: 'row' });
if (!alive) break; // Client disconnected
}
await send({ data: 'complete', event: 'end' });
});

Formatting the frames by hand with res.write() still works, and is what you want when the stream is not SSE.


These are pre-injected into every script, so they need no require or import:

// $ - Bun.$ for shell commands
const output = await $`ls -la /home/user`.text()
const result = await $`echo "Hello"`.text()
// Database - bun:sqlite Database constructor (require('bun:sqlite').Database)
const db = new Database('/hoody/databases/app.db')
const rows = db.query('SELECT * FROM users').all()
// Node.js built-ins (pre-injected)
crypto.randomUUID() // crypto module
fs.readFileSync('/path/to/file') // fs module
path.join('/home', 'user') // path module
// Also available: http, https, net, tls, child_process

Return any value and Hoody Exec handles the content type, serialization, and status code:

// Return Object → Auto-formatted JSON (Content-Type: application/json)
return { users: [...], count: 42 };
// Return Array → Auto-formatted JSON array
return [{ id: 1 }, { id: 2 }];
// Return String (HTML detected) → Content-Type: text/html
return "<!DOCTYPE html><html><body>Hello</body></html>";
// Return String (other) → Content-Type: text/plain
return "Plain text response";
// Return Number → Content-Type: text/plain (stringified, e.g. "42")
return 42;
// Return Boolean → Content-Type: text/plain (stringified, e.g. "true")
return true;
// Return Buffer → Auto-detected MIME type (images, PDFs, files)
return fs.readFileSync('/path/to/image.png'); // Automatic image/png
// Return Error → 500 status with error details
return new Error("Something went wrong");
// Return Nothing → Empty response, 204 unless you set another status
// (no return statement or return undefined)

For full control, use res directly to bypass auto-handling:

res.writeHead(200, { 'Content-Type': 'application/xml' });
res.end('<?xml version="1.0"?><data>Custom</data>');


Scripts can use these npm packages without listing them in a package.json. They are not all installed in advance: one not yet present in the scripts directory is installed the first time a script uses it, so that first request is slower and needs the container to reach the npm registry:

PackageDescription
@ai-sdk/openaiVercel AI OpenAI provider
aiVercel AI SDK
axiosHTTP client
cheerioHTML parser (jQuery-like)
cookieCookie parser; v2 API: parseCookie, parseSetCookie, stringifyCookie, stringifySetCookie
dayjsDate library
ejsTemplate engine
hoody-sdkHoody SDK: HoodyClient and the kit services (hoody-sdk/files, hoody-sdk/sqlite, hoody-sdk/exec, and more); updates itself, see below
jsonwebtokenJWT creation/verification
lodashUtility library
markedMarkdown parser
mime-typesMIME type detection
openaiOfficial OpenAI SDK
papaparseCSV parser
playwright-coreBrowser automation (no bundled browser)
puppeteer-coreHeadless Chrome automation (no bundled browser)
qrcodeQR code generator (PNG, SVG, data URL)
rss-parserRSS/Atom feed parser
sanitize-htmlHTML sanitizer
uuidUUID generation
wsWebSocket client/server
xml2jsXML parser and builder
yamlYAML parser
zodSchema validation (also exposed as z, but only once the package is installed: write const { z } = require('zod') so it gets installed)

Any other npm package that your script names with a plain string in require() or import is installed automatically before the script runs, so maintaining package.json is optional. A package named by a computed value, such as require(name), is not detected: install it first with the package-management endpoints. Packages install at their latest published version; use the package-management endpoints when you need an explicit manifest or a specific version.

A version inside the specifier, such as require('lodash@4.17.21') or import x from 'hoody-sdk@1.0.0-beta.16', is not supported. All scripts in a scripts directory share one copy of each package, so the script fails with ERR_VERSIONED_SPECIFIER and nothing is installed. Pin the version in the scripts directory’s package.json instead (POST /api/v1/exec/package/update) and require the bare name.

The packages listed above are the ones this service publishes a version range for. If a copy older than that range is present in your workspace — usually pulled in as another package’s dependency — the first script that needs it brings it up to date automatically, once. A copy newer than the published range is left alone.

hoody-sdk is installed from npm like the other packages in the table, together with its subpaths. When Hoody Exec starts and the scripts directory has no copy, it also installs one in the background. Every 6 hours it checks npm for a newer hoody-sdk and downloads it in the background; the new version takes effect the next time Hoody Exec restarts, and until then scripts keep the version they have. It never downgrades. The server flag --sdk-update-interval <hours> sets the interval (from 0.1 to 168), and 0 turns the check off.

To control the version, declare hoody-sdk in the scripts directory’s package.json, for example with POST /api/v1/exec/package/update. An exact version ("1.0.0-beta.16") keeps the SDK on that version: automatic updates leave it alone. A range ("^1.0.0") lets automatic updates move only within that range. The declaration applies to every script that shares the scripts directory; remove it to return to automatic updates.

For the kits of the script’s own container, use the ready-made hoody client. Import the package yourself only for other containers or accounts:

import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ token: env.HOODY_TOKEN });
const { data } = await client.api.containers.list();
return { containers: data?.containers ?? [] };

ws is the one exception to that table: require('ws') returns the WebSocket implementation built into the runtime, not the copy in your workspace, so the range above does not describe what you get. Requiring a file inside the package, such as ws/lib/websocket.js, still loads the installed copy.

A package’s files arrive over a short window while it installs. A script that reaches for a package whose main file is still empty during that window gets an error asking it to retry. Only the file the specifier resolves to is checked (the main file, or the subpath you asked for), so a package can still load during the window with some of its other files missing. Once nothing is installing, packages load normally — including the few published packages that ship an empty entry file on purpose.


Scripts are stored in instance-specific directories:

/hoody/storage/hoody-exec/scripts/default/1/ # exec-1
/hoody/storage/hoody-exec/scripts/default/2/ # exec-2
/hoody/storage/hoody-exec/scripts/default/test/ # exec-test

The instance number (1, 2, test, and so on) maps to the hostname:

  • exec-1 → scripts/default/1/
  • exec-2 → scripts/default/2/
  • exec-test → 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.PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com, does not reach your container.

To create scripts programmatically, use the scripts/write endpoint. The path you send is relative to the instance directory, so the request below writes /hoody/storage/hoody-exec/scripts/default/1/api/hello.ts:

Terminal window
curl -s -X POST "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/scripts/write" \
-H "Content-Type: application/json" \
-d '{
"path": "api/hello.ts",
"content": "// @mode serverless\nreturn { hello: \"world\" };",
"createDirs": true,
"validate": true
}'

When you can’t or don’t want to use the API, put the file straight into the instance directory on the container filesystem: from a terminal in the container, through Files, or with any editor that reaches the container. The URL path is the file’s path below the instance directory, without the extension:

Terminal window
mkdir -p /hoody/storage/hoody-exec/scripts/default/1/api
cat > /hoody/storage/hoody-exec/scripts/default/1/api/hello.ts <<'EOF'
return { hello: "world" };
EOF
# Served at https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/hello
  • Create missing folders first (mkdir -p). Nothing creates them for you on this path.
  • No restart or reload is needed, except for schedules: after adding or changing @schedule, @schedule-timeout or the @timeout a scheduled run uses, call POST /api/v1/exec/schedules/reload. The file is read on every request, so a new file or an edit is live on the next request, in serverless and worker mode alike. This holds for route scripts only: a helper file a script loads with require or import stays cached once loaded, and an edit to it takes effect only after a restart (POST /api/v1/exec/system/restart).
  • Nothing is validated on this path. Check a file with the validation endpoints if you want the same checks scripts/write runs.

Hoody Exec runs on the Bun runtime. Bun starts about 3x faster and uses less memory, supports the latest ECMAScript features, and improves module and dependency handling; the fast startup suits serverless script execution.