Magic Comments
Section titled “Magic Comments”Magic comments configure script behavior from the top of the file itself. You do not change code, write a configuration file, or set environment variables; you edit a comment and the behavior changes.
// @mode worker// @cors reflective// @timeout 5000// @log-level standard// @ai true
// Your code starts here...return { hello: 'world' };How magic comments work
Section titled “How magic comments work”Magic comments are parsed at script load time. Put them at the very top of the file, before any code, imports, or expressions.
Only the first 30 lines of the file are scanned, and every line counts: a license header, a long JSDoc block, or a run of blank lines above your directives can push them out of the window, where they are ignored without an error. The window is published as parse_window_lines in the magic-comments schema. Keep the directives on the very first lines and put any prose header below them. The one exception is @return-type and @return-type-mode: they are read from comments anywhere in the file, so they still take effect below line 30.
Rules:
- Start with
//followed by a space and@ - One comment per line
- Comment names are case-sensitive
- Most directives take everything after the name as the value; a few, such as
@token,@modeand@log-level, read only the first word. Either way, never put a trailing comment on a directive line - A comment’s value overrides the default
- The validation API parses and checks them
Validation example:
# Validate magic comments in a scripthoody exec magic comments validate \ --code $'// @mode worker\n// @cors reflective\n// @timeout 5000\nreturn {};'const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER});const result = await containerClient.exec.magicComments.validate({ code: '// @mode worker\n// @cors reflective\n// @timeout 5000\nreturn {};'});console.log(result.data); // { magicComments: { ... }, warnings: [], returnType: null, message: "Magic comments parsed successfully" }curl -s -X POST "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/validate/magic-comments" \ -H "Content-Type: application/json" \ -d '{ "code": "// @mode worker\n// @cors reflective\n// @timeout 5000\nreturn {};" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Validates a script’s magic comments and returns the parsed result, without running the script.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/validate/magic-comments&method=POST&json={"code":"//%20@mode%20worker\n//%20@cors%20reflective\n//%20@timeout%205000\nreturn%20{};"}&response=transparent Execution and performance
Section titled “Execution and performance”| Comment | Values | Default | Description |
|---|---|---|---|
@mode | worker | serverless | serverless | Execution mode. Worker mode keeps one long-lived JavaScript context per instance, shared by all its worker scripts, so their globals are shared too; serverless mode builds a fresh context per request (functions registered for remote messages and calls keep the context of the run that registered them). The per-instance shared object persists in both (in memory: a restart or the eviction of an idle instance clears it), and neither is a security boundary |
@enabled | true | false | true | Enable or disable script execution. A disabled script does not run. Setting it does not close script WebSockets already open; their handlers keep running. Remote streams and attach sockets close within about 15 seconds. It has no effect in pre.ts or post.ts, where no magic comment has any effect |
@timeout | ms | 90s/5m/2h | 0 | unlimited | 3600000 | Soft request timeout (default 1 hour). Bare number = ms; unit suffixes (s/m/h/d) accepted. Values above 2147483647 ms (about 24.8 days) are capped at it. Past the deadline an un-started response gets 504; the timeout does not interrupt an already-streaming response or kill in-flight script execution. 0/unlimited = no timeout. Requests from a proxy hook use the hook’s own timeout instead |
@await-promises | true | false | true | Accepted so existing scripts keep working, but it has no effect. Returned promises are always awaited |
@concurrent | positive integer | true | false | true | Max concurrent executions (applies in both worker and serverless mode, to HTTP requests, cron, and remote send and call; WebSocket messages and remote eval are not held back by it). true = unlimited, false = single serial execution, N = at most N in parallel. Any other value is ignored with a warning |
@schedule | cron expression | (none) | Run the script on a cron schedule, always in UTC (e.g., 0 9 * * * runs at 09:00 UTC). Cannot be combined with @websocket. A change made through the scripts API applies at once; one made through the magic-comments API is applied in the background just after the call returns; after editing the file any other way, call POST /api/v1/exec/schedules/reload. See Scheduling |
@schedule-timeout | ms | 90s/5m/2h | 0 | unlimited | @timeout, else 30000 | Per-fire wall-clock timeout for @schedule runs. HTTP requests keep @timeout; a scheduled run without @schedule-timeout uses the script’s @timeout, else 30 s. The value is read when the schedule is registered: after editing either directive directly, call POST /api/v1/exec/schedules/reload. Like @timeout it is soft: the fire is recorded as timed out and the next boundary can start, but the timed-out run is not killed (a returned stream or generator does stop being read at the deadline). It keeps running, keeps its @concurrent slot until it finishes, and still counts toward the limit of 10 unfinished fires per script (further fires are skipped past that limit) |
Examples:
// One long-lived context, shared state// @mode worker// 60 second timeout// @timeout 60000// Process one request at a time// @concurrent false@timeout 0 and @timeout unlimited both mean no timeout. Use either with care. Two separate limits still apply: reading a request body (without @rawBody) must finish within 30 seconds or the request gets 408, and pre.ts before a WebSocket handshake gets at most 30 seconds before the upgrade is refused with 504. Separately, synchronous code that blocks the server for longer than its watchdog limit (about 30 seconds by default) gets the server restarted, which ends every running script and clears in-memory state such as shared.
CORS configuration
Section titled “CORS configuration”| Comment | Values | Default | Description |
|---|---|---|---|
@cors | reflective | * | URL | none | reflective | Origin policy for the actual response. With no @cors* directive the server’s global reflective CORS applies; none strips CORS headers; reflective/URL set the allowed origin. * is an alias for reflective — the response always names the requesting origin, never a literal * |
@cors-credentials | true | false | false | Allow credentials (cookies, auth headers). Works with any origin policy that names an origin, including @cors *, because that reflects the requesting origin rather than sending a literal * |
@cors-methods | GET,POST,... | (server default) | Access-Control-Allow-Methods is preflight-only: the browser reads it from the OPTIONS response, which the server answers before the script runs, so the per-script value has no effect. The preflight allows the method the browser asked for (a default list if none was sent) |
@cors-headers | Authorization,... | (server default) | Access-Control-Allow-Headers is preflight-only (answered before the script runs, so the per-script value has no effect there): the preflight allows the headers the browser asked for (a default list if none were sent). The per-script value drives Access-Control-Expose-Headers on the actual response |
@cors-max-age | number (seconds) | (server default) | Preflight cache hint. Not applied per-script: the preflight is answered before the script runs, with a fixed max age of 3600 seconds |
Examples:
// Mirror request origin (development)// @cors reflective// Allow cookies// @cors-credentials trueThe other @cors values are * (any origin, for testing), a specific origin such as https://app.example.com (production), and none (strip CORS headers). Set exactly one @cors per script. The last one in the file wins, and @cors none also disables every @cors-* sub-directive below it, unless a later @cors <origin> reopens the config.
Logging and debugging
Section titled “Logging and debugging”| Comment | Values | Default | Description |
|---|---|---|---|
@log-level | none | minimal | standard | full | debug | standard | Logging verbosity |
@debug | true | false | false | Shortcut for @log-level debug |
@log-request-body | full | redacted | off | true | false | full | Log incoming request bodies (not with @rawBody, where the server never reads the body itself). A value the parser does not recognise fails closed to redacted, not to the default |
@log-response-body | full | redacted | off | true | false | full | Log response bodies. Same fail-closed rule as @log-request-body |
@log-max-body-size | bytes | 512kb/1mb | 1048576 | Max body size to log. Bare number = bytes; unit suffixes (kb/mb/gb) accepted |
@log-exclude-headers | header names | authorization cookie x-token | Extra headers to exclude from logs. The names you list are added to the three defaults, which you cannot switch off from a script |
@log-retention-days | 0 to 3650 | 14 | Days to keep this script’s per-request logs (values above the 3650-day cap are clamped). Each log keeps the value in force when it was written, so a change applies to new logs only. Access, cron and remote logs are not affected |
@debug-instrument | true | false | false | Enable code instrumentation for detailed tracing. @log-level debug turns it on by itself, even with false; traces include variable values |
Examples:
// Maximum logging, including code traces// @log-level debug// Log request payloads// @log-request-body true// Log response payloads// @log-response-body true// Also hide these headers// @log-exclude-headers x-api-key x-session// Keep logs for 30 days// @log-retention-days 30// Detailed code tracing (request logs show traces only at @log-level debug;// WebSocket and remote runs write them to the server log at any level but none)// @debug-instrument trueLog levels:
none: no per-request log (the access log still records the request)minimal: the request line plus the final status and duration, without the detailed request and response logs (the request’s headers and query parameters are still saved, redacted, in its metadata, and console output is still logged)standard: request metadata and headers (the default)full: the most detailed level belowdebug; request and response logs matchstandarddebug: everything infullplus the script’sconsole.debug()output and automatic code-instrumentation traces
Body logging is controlled separately by @log-request-body and @log-response-body, not by the level. Every level except none can record request bodies, and every level except minimal and none can record response bodies. To keep payloads out of the logs, set both directives to off.
AI integration
Section titled “AI integration”| Comment | Values | Default | Description |
|---|---|---|---|
@ai | true | false | true | Enable AI helpers. Injects model, openai, ai, generateText, streamText, and generateObject |
@ai-model | model name | hoody-ai/hoody-free | AI model to use. Overrides the server default (e.g., deepseek/deepseek-v4-pro) |
@ai-temperature | 0 to 2 | (none) | AI temperature parameter (provider default when unset) |
@ai-max-tokens | positive integer | (none) | Max tokens for AI response (provider default when unset). Other values are ignored |
@ai-key | string | server-configured | AI API key override (default uses the server’s --ai-key flag) |
Example:
// @mode serverless// @ai true// @ai-model deepseek/deepseek-v4-pro// @ai-temperature 0.3
const { text } = await ai.generate({ prompt: `Summarize this: ${req.body.content}`});
return { summary: text };@ai-temperature and @ai-max-tokens supply defaults for ai.generate, ai.stream, and ai.object. The top-level generateText, streamText, and generateObject do not apply them, so pass options explicitly when you use those.
Authentication
Section titled “Authentication”| Comment | Values | Default | Description |
|---|---|---|---|
@token | string | (none) | Per-endpoint shared secret. Requests must provide the token via one of four methods. @token takes one word: in // @token s3cret rotate monthly the token is s3cret and the rest of the line is ignored, with a warning. @token is repeatable: each line adds one accepted token, and a request that matches any of them passes. Remove a line to revoke that token, but keep at least one: with no @token line left, the endpoint is public. A change applies to new requests and new WebSocket connections; an open WebSocket stays connected, but like any script edit the change puts sockets opened before and after it into separate ws.broadcast sets (remote sessions are checked again about every 15 seconds, and closed if their credentials are no longer accepted; removing every @token when no @remote-token is declared makes remote messages and calls public, so open sessions stay; eval still needs an eval-scoped @remote-token) |
Add @token to protect an endpoint, with no middleware, auth library, or configuration file involved:
// @token my-secret-key-123// @cors reflective
return { data: 'only authenticated requests see this' };Clients authenticate using any of these methods (checked in priority order):
| Priority | Method | Example |
|---|---|---|
| 1a | Authorization: Bearer <token> | curl -H "Authorization: Bearer my-secret-key-123" ... |
| 1b | Authorization: Basic (password field) | curl -u user:my-secret-key-123 ... or curl -u :my-secret-key-123 ... |
| 2 | X-Token header | curl -H "X-Token: my-secret-key-123" ... |
| 3 | ?token= query parameter | curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/report?token=my-secret-key-123" |
Security details:
- Constant-time comparison (SHA-256 +
timingSafeEqual): immune to timing attacks - Hoody Exec redacts the token in its API responses and access logs (
[REDACTED]), and by default in its per-request logs too. Anything your script logs or returns itself is recorded as is - When the request authenticates with
?token=, it is stripped fromreq.urlandmetadata.parametersbefore your script runs. If a higher-priority credential is used instead, a?token=parameter stays visible to the script - CORS preflight (
OPTIONS) returns204without requiring auth (spec-correct behavior), except for a request a proxy hook sends to its target script - WebSocket
upgraderequests are also gated; pass the token via a header or?token= - Empty or whitespace-only values are ignored (endpoint stays public)
See Authentication for the full guide with examples.
Remote operations
Section titled “Remote operations”| Comment | Values | Default | Description |
|---|---|---|---|
@remote-messages | (flag) | off | Allow event streams (X-Hoody-Remote: events), send, and the hoody-remote.v1 attach socket on the script’s URL |
@remote-call | (flag) | off | Allow call of the functions the script exposes with remote.expose(name, fn) |
@remote-eval | (flag) | off | Allow eval in the script’s latest run. Also needs a @remote-token with the eval scope |
@remote-token | <name> <secret|env:NAME> [scope] | (none) | Named token for remote operations. Repeatable, with a different name on each line: a second line with a name already used is ignored, so give a new token a new name when rotating. scope is a comma list of messages, call, eval (default messages,call). When any is declared, @token no longer admits remote operations |
// @mode worker// @remote-messages// @remote-call// @remote-token dashboard env:DASHBOARD_TOKEN messages// @remote-token ops env:OPS_TOKEN messages,callThe three flags accept the bare directive, true or false; any other value is ignored, so the line has no effect (a flag that was off stays off). A @remote-token secret shorter than 16 characters matches nothing, and a @remote-token line whose values do not parse still counts as declared, so it locks remote operations rather than opening them. A misspelled comment name is not recognised at all and protects nothing. See Remote Messages and Events for the operations, frames and limits.
Advanced features
Section titled “Advanced features”| Comment | Values | Default | Description |
|---|---|---|---|
@websocket | (flag) | (none) | Enable WebSocket support. Requires worker mode and cannot be combined with @schedule |
@websocket-pre | true | false | true | Run the directory’s pre.ts (or pre.js when there is no pre.ts) once before each WebSocket handshake. false opens the socket without it |
@rawBody | (flag) | (none) | Skip the request-body auto-parser, so req stays a raw Node Readable stream (for large uploads, streaming, or passthrough). Accepts @rawBody, @rawBody true, or @rawBody false |
| @label | string | (none) | Script classification label for filtering and organization |
| @return-type | TypeScript type | (none) | Type of the script’s return value, written inline: primitives, literals, object shapes, arrays, records, unions and interface bodies. Array<T>, Promise<T> (checked against the awaited value) and Record<K, V> work too. A named type such as User, other generics and tuples are not supported and fail validation |
| @return-type-mode | strict | warn | dev | strict | Enforcement mode for @return-type validation. strict rejects on mismatch, warn logs the mismatch and continues. dev also rejects in every environment: outside production the client gets a 400 with the validation details, and in production an ordinary error, unless the script already sent its response (with res.json(), for example), in which case the client keeps what was sent. Validation runs only when the script returns a value other than null or undefined, in every mode. A type the server cannot parse, such as Record<string>, turns validation off (the error is only logged), even in strict mode |
| @description | text | (none) | Description of the script, shown in script listings. The generated OpenAPI spec does not use it; describe the API in the script’s .openapi.json file instead |
| @tags | Tag1, Tag2 | (none) | Tags for the script, used to filter script listings. The generated OpenAPI spec does not use them; set API tags in the script’s .openapi.json file instead |
Examples:
// @mode worker// Enable WebSocket handlers// @websocket// Classify this script// @label user-api// @return-type { id: string, name: string, email: string }// Enforce return-type at runtime// @return-type-mode strict// @description User profile API// @tags Users, Profile@websocket and @rawBody are the strictest of all: they are recognised only as the bare directive, or followed by exactly true or false. // @websocket // enable sockets and // @rawBody needed for uploads are both ignored outright, with no warning in the logs.
Magic comments API
Section titled “Magic comments API”Manage magic comments through the API:
| Endpoint | Description |
|---|---|
GET /api/v1/exec/magic-comments/schema | Get the schema of the magic comments this API manages: all except @rawBody, @return-type and @return-type-mode, which you edit in the script itself |
GET /api/v1/exec/magic-comments/read | Read magic comments from a script |
PUT /api/v1/exec/magic-comments/update | Update magic comments on a script |
POST /api/v1/exec/magic-comments/bulk-update | Bulk update magic comments across multiple scripts |
# Read magic comments from a specific scripthoody exec magic comments get --path "default/1/api/hello.ts"// Get the full magic comments schemaconst schema = await containerClient.exec.magicComments.getSchema();console.log(schema.data);
// Read magic comments from a specific scriptconst comments = await containerClient.exec.magicComments.get({ path: 'default/1/api/hello.ts' });console.log(comments.data); // { path: "default/1/api/hello.ts", comments: { mode: "serverless", cors: "reflective", ... } }# Get the full magic comments schemacurl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/schema"
# Read magic comments from a specific scriptcurl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/read?path=default/1/api/hello.ts"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Gets the canonical schema of supported magic comments, and reads the magic comments currently set on a script. The path resolves like scripts/read: first under the call’s scope (api/hello.ts), then, when no file is there, relative to the scripts root (default/1/api/hello.ts). resolvedPath in the response names the file that was read.
# Schema
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/schema&method=GET&response=transparent
# Read
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/read?path=default/1/api/hello.ts&method=GET&response=transparent Quick reference
Section titled “Quick reference”All magic comments, by category:
| Category | Comments |
|---|---|
| Execution | @mode, @enabled, @timeout, @await-promises, @concurrent, @schedule, @schedule-timeout |
| Authentication | @token |
| Remote operations | @remote-messages, @remote-call, @remote-eval, @remote-token |
| CORS | @cors, @cors-credentials, @cors-methods, @cors-headers, @cors-max-age |
| Logging | @log-level, @debug, @log-request-body, @log-response-body, @log-max-body-size, @log-exclude-headers, @log-retention-days, @debug-instrument |
| AI | @ai, @ai-model, @ai-temperature, @ai-max-tokens, @ai-key |
| Advanced | @websocket, @websocket-pre, @rawBody, @label, @return-type, @return-type-mode, @description, @tags |