Writing Scripts
Section titled “Writing Scripts”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.
Basic script structure
Section titled “Basic script structure”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.
// @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 paramconst user = { id, name: 'Example' }; // Look the user up here
// Return value auto-formatted as JSONreturn { 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 WebRequestinstead ofreq: read the body withawait request.json()and route parameters fromcontext.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,$, andDatabaseare pre-injected. - Magic comments go at the very top, before any code.
- Return a value to send it as the response, or use
resfor full control.
Automatically available variables
Section titled “Automatically available variables”Every script receives these parameters automatically, with no imports or configuration:
Core HTTP objects
Section titled “Core HTTP objects”// req - Incoming HTTP requestreq.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)Response object
Section titled “Response object”// res - HTTP response (for full control)res.statusCode = 200 // Set status and headers firstres.setHeader('Content-Type', 'application/json')res.setHeader('X-Custom', 'value')res.end(JSON.stringify({ success: true })) // then send the bodyMetadata object
Section titled “Metadata object”// metadata - Request context and routing infometadata.executionId // Unique execution IDmetadata.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 URLmetadata.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 objectsState and tools
Section titled “State and tools”// shared - Persistent state per deployment (script hostname plus exec ID), in both modes; scripts in the same deployment share itshared.cache ??= new Map() // Persists across requestsshared.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 - Loggerconsole.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 presentconst lodash = require('lodash') // Installed before the script runs if not present
// env - Frozen environment snapshot for this invocationenv.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')WebSocket context (ws)
Section titled “WebSocket context (ws)”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 patternws.open = (socket, req) => { ... }ws.message = (socket, data) => { ... }ws.close = (socket, code, reason) => { ... }ws.error = (socket, error) => { ... }
// Or the event emitter patternws.on('open', (socket, req) => { ... })ws.on('message', (socket, data) => { ... })ws.on('close', (socket, code, reason) => { ... })ws.on('error', (socket, error) => { ... })
// Connection managementws.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.connectionsws.broadcast(data, excludeSocket) // Send to all except one client
// Socket data: available on each socket instancesocket.data.ip // Client IP addresssocket.data.url // Request URLsocket.data.headers // Request headerssocket.data.parameters // Query-string values plus dynamic route parameters (route parameters win)socket.data.executionId // Unique execution ID for this connectionPost middleware result (mainResult)
Section titled “Post middleware result (mainResult)”// 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};AI helpers (@ai, enabled by default)
Section titled “AI helpers (@ai, enabled by default)”// Available by default (set // @ai false to disable); no imports needed.// Not available in pre.ts and post.tsai // 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 schemaopenai // 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 completionsstreamText // Vercel AI SDK: stream text responsesgenerateObject // Vercel AI SDK: generate structured JSON objectsSee AI-Powered Scripts for full usage examples and model configuration.
Environment variables
Section titled “Environment variables”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.envforapi/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_BASE=https://api.example.comTIMEOUT_MS=5000const 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') };Hoody SDK client (hoody)
Section titled “Hoody SDK client (hoody)”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.
const hostname = await hoody.files.readText('/etc/hostname');const { stdout } = await hoody.terminal.run('uptime -p'); // also exitCode, stderrreturn { hostname: hostname.trim(), uptime: stdout.trim() };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:
| Variable | Used 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_REALM | The 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 noHOODY_TOKENfails before anything is sent: a401error with codeHOODY_CLIENT_NOT_CONFIGURED, naming the variables to set. Kit calls still work. - Another container selected:
hoodyalways talks to its own container. IfHOODY_CONTAINERnames a different one, reading a kit fails with an error pointing you towithContainer(below). - An API address that covers the kits: if
HOODY_BASE_URLis a parent of the kits’ address (for examplehttps://hoody.com, while kits live under….containers.hoody.com), your token would reach the kits too. The first use ofhoodythen fails withHOODY_CLIENT_NOT_CONFIGURED, namingHOODY_BASE_URL. Use the API host itself, such ashttps://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_TOKENreturn 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.
Check SDK calls before they run
Section titled “Check SDK calls before they run”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-typeslooks up methods: their call path, full signature, description and an example.typecheck: trueonvalidate/typescript,validate/scriptandscripts/writeruns 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.
Look up a method
Section titled “Look up a method”$EXEC below is your Hoody Exec address. See URL structure for where the project ID, container ID and server name come from.
EXEC="https://$PROJECT_ID-$CONTAINER_ID-exec-1.$SERVER_NAME.containers.hoody.com"
# Every kit on the client, with its number of methodscurl "$EXEC/api/v1/exec/sdk-types"
# Methods of one kit whose path, name or description contains every word of qcurl "$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 asfiles,sqlite,terminal,browser,apiorexec. Useclientfor the client’s own methods, such aswithContainer. An unknown kit answers404UNKNOWN_KIT, with the valid names indetails.kits.q: optional; every word must match.limit: 1 to 100, default 20.totalcounts every match, andtruncated: truemeans 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(…): readparamsandsummaryinstead.incomplete: trueon a parameter means its type was too complex to list in full; read thesignature, or useraw=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 asFilesReadOptions, and you need its fields.- No SDK installed yet: the answer is
404SDK_NOT_INSTALLED. Hoody Exec installs the SDK when it starts, so this passes on its own.
Type-check a script
Section titled “Type-check a script”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 ahoodycall (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 fullsdk-typesentry, withsignature,paramsandexample.note: for a forgottenawait. For exampleconst 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:
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/typescriptalways answers 200; readvalidanddiagnostics.validate/scriptputs the result underresults.typecheck.scripts/writewithtypecheck: truerefuses a script with type errors:400, with the errors indetails.validation.typecheck.diagnostics. Nothing is written.
- Line numbers:
lineandcolumnpoint 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.
hoodyis the SDK client;crypto,WebSocketandBufferare the Node versions. - Helper files: values from your own helper files (
./lib/x) are not checked; they are treated asany. - 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: falseand says why intypecheckError(typecheck.erroronvalidate/scriptandscripts/write). Nothing is blocked:scripts/writestill writes.
Without typecheck, these endpoints behave as before: a transpile check only, answering typeChecked: false.
Response helpers
Section titled “Response helpers”The res object is enhanced with Express-like convenience methods:
res.json({ data: 'value' }) // Send JSON responseres.send('text') // Send text responseres.html('<h1>Hello</h1>') // Send HTML responseres.redirect('/new-path') // HTTP redirectres.stream('text/event-stream') // Set streaming headers, then res.write()/res.end() (default text/plain)res.sse(async (send) => { ... }) // Server-Sent Events with backpressure handlingres.status(404) // Set status code (chainable)Example with chaining:
// Return a 201 JSON responseres.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.
Bun globals and Node.js built-ins
Section titled “Bun globals and Node.js built-ins”These are pre-injected into every script, so they need no require or import:
// $ - Bun.$ for shell commandsconst 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 modulefs.readFileSync('/path/to/file') // fs modulepath.join('/home', 'user') // path module// Also available: http, https, net, tls, child_processReturn values
Section titled “Return values”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 arrayreturn [{ id: 1 }, { id: 2 }];
// Return String (HTML detected) → Content-Type: text/htmlreturn "<!DOCTYPE html><html><body>Hello</body></html>";
// Return String (other) → Content-Type: text/plainreturn "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 detailsreturn 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>');Real client IPs
Section titled “Real client IPs”Pre-installed packages
Section titled “Pre-installed packages”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:
| Package | Description |
|---|---|
@ai-sdk/openai | Vercel AI OpenAI provider |
ai | Vercel AI SDK |
axios | HTTP client |
cheerio | HTML parser (jQuery-like) |
cookie | Cookie parser; v2 API: parseCookie, parseSetCookie, stringifyCookie, stringifySetCookie |
dayjs | Date library |
ejs | Template engine |
hoody-sdk | Hoody SDK: HoodyClient and the kit services (hoody-sdk/files, hoody-sdk/sqlite, hoody-sdk/exec, and more); updates itself, see below |
jsonwebtoken | JWT creation/verification |
lodash | Utility library |
marked | Markdown parser |
mime-types | MIME type detection |
openai | Official OpenAI SDK |
papaparse | CSV parser |
playwright-core | Browser automation (no bundled browser) |
puppeteer-core | Headless Chrome automation (no bundled browser) |
qrcode | QR code generator (PNG, SVG, data URL) |
rss-parser | RSS/Atom feed parser |
sanitize-html | HTML sanitizer |
uuid | UUID generation |
ws | WebSocket client/server |
xml2js | XML parser and builder |
yaml | YAML parser |
zod | Schema 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.
Script storage paths
Section titled “Script storage paths”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-testThe 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:
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 }'Adding files by hand
Section titled “Adding files by hand”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:
mkdir -p /hoody/storage/hoody-exec/scripts/default/1/apicat > /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-timeoutor the@timeouta scheduled run uses, callPOST /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 withrequireorimportstays 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/writeruns.
Bun runtime
Section titled “Bun runtime”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.