Skip to content
Hoody.com

A running script can publish events to up to 64 listening clients at a time per room, receive messages from them, and answer calls to functions it exposes. Everything happens on the script’s own URL: a request header (or one query parameter) turns an ordinary request into a remote operation, so there is no separate API to address. Each capability is off until a magic comment switches it on.

// @mode worker
// @remote-messages
// @remote-token dashboard dash-7f3a9c2e5b1d4f60 messages
remote.on('message', (message) => {
if (message.type !== 'start') return { accepted: false };
runJob(); // not awaited: the reply goes back at once
return { accepted: true };
});
async function runJob() {
for (let pct = 0; pct <= 100; pct += 25) {
remote.emit('progress', { pct });
await new Promise((resolve) => setTimeout(resolve, 500));
}
remote.emit('done', { at: new Date().toISOString() });
}
return { status: 'ready' };

A client that listens on this script’s URL receives every progress and done event; a client that sends { "type": "start" } starts the job.


OperationRequest on the script’s URLNeeds
eventsGET with X-Hoody-Remote: events, or GET ?hoody-remote=events. The answer is a Server-Sent Events stream@remote-messages
sendPOST with X-Hoody-Remote: send and { "message": … }@remote-messages
callPOST with X-Hoody-Remote: call and { "name": "…", "args": [ … ] }@remote-call
capabilitiesGET with X-Hoody-Remote: capabilitiesnothing
attachWebSocket offering the subprotocol hoody-remote.v1: events, send and call on one socket@remote-messages
evalPOST with X-Hoody-Remote: eval@remote-eval and an eval-scoped token

Every answer to a marked request that remote dispatch handles carries the header X-Hoody-Remote-Version: 1. The header confirms that the server supports remote operations. It is missing on OPTIONS requests (answered with 204 before remote dispatch) and on requests refused before dispatch, such as one with a malformed URL, so only treat its absence on a normal response as “not supported”. A request that sends the header twice, names an unknown operation, or puts any value other than events in ?hoody-remote= is refused with 400 REMOTE_UNKNOWN_OP; the marker is never silently ignored.


CommentEffect
@remote-messagesAllows events, send and attach
@remote-callAllows call
@remote-evalAllows eval. It also needs a @remote-token with the eval scope
@remote-token <name> <secret|env:NAME> [scope]Declares a named token. Repeatable. scope is a comma list of messages, call and eval; without one the token gets messages,call. eval is never implied

The three capability comments take no value, or exactly true or false. Any other value is ignored: that line has no effect, and if the comment appears more than once, the last valid line wins.

A @remote-token name is 1 to 64 letters, digits, dots, underscores or hyphens, and each name may appear once. The secret is either written inline or read from the script’s .env companions with env:NAME. A secret shorter than 16 characters, or an env: name with no value, matches nothing. A @remote-token line whose values do not parse still counts as declared and matches nothing, so a typo in the values locks the script instead of opening it. The comment itself must be spelled exactly, // @remote-token followed by a space (a bare // @remote-token with nothing after it counts as a declared token that matches nothing): a line spelled differently is not recognised and protects nothing. Like every magic comment, @remote-token lines must sit in the first 30 lines of the file, before any code; one further down is ignored.

You can also set these comments through the magic-comments API. Every refusal for a disabled capability carries details.fix with both the comment to add and the exact magic-comments/update call.


remote.emit(event, data) sends one frame to every client listening to the script at that moment.

  • event is a string of 1 to 128 characters. attached, end, console and generation are reserved for the server’s own frames and throw a TypeError.
  • data must be plain data and at most 8 MiB once serialized. A value that cannot be sent throws a TypeError naming the first bad path. See What can be sent.
  • The return value is { delivered, dropped }: how many clients received the frame, and how many could not take it because their queue was full (those clients are disconnected with reason overflow).
  • With no client listening, emit returns { delivered: 0, dropped: 0 } and does no serialization work.

Any run of the script can emit: an HTTP request, a @schedule fire, a send or call handler, or background work the script started. Events are not stored. A client receives only the events emitted while it is connected, and nothing is replayed when it reconnects.

Listeners are grouped in rooms. A room is one script, with one set of route parameter values, on one deployment (the hostname together with its exec ID). An event emitted while handling /rooms/42 through rooms/[id].js reaches clients listening on /rooms/42, not those on /rooms/43, and never crosses to another exec ID or subdomain.

Remote operations work in both execution modes. Background work that outlives the request, like runJob() above, belongs in worker mode, where the script’s context stays alive between requests.


Terminal window
# The token is read from HOODY_EXEC_REMOTE_TOKEN (or --remote-token-file)
export HOODY_EXEC_REMOTE_TOKEN=dash-7f3a9c2e5b1d4f60
hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/jobs/build events \
--tail info --count 20 --timeout 30s

The stream opens with an : open comment, then sends each frame as one data: line holding a JSON object, and a : ping comment every 15 seconds. Frames carry no SSE event: field, so an EventSource delivers all of them to onmessage; the frame’s own event member tells them apart. Remote answers carry the script’s CORS headers (reflective unless the script sets @cors), so a page on another origin can connect.

?tail= adds the script’s console output to the stream. It takes error, warn, info or debug, and includes every level at or above the one named (console.log counts as info). Without it, no console lines are sent.

Opening a stream does not run the script. A client that connects before anything emits simply waits.

eventShapeWhen
attached{ "event": "attached", "v": 1, "generation": "…", "path": "default/1/jobs/build.js" }First frame of every stream and socket; path is the script’s path from the scripts root
your event name{ "event": "progress", "data": { "pct": 50 }, "generation": "…" }Each remote.emit
console{ "event": "console", "level": "info", "args": [ … ], "kind": "…" }Each console call at or above the tail level. console.dir and console.table are not included
generation{ "event": "generation", "from": "…", "to": "…" }A changed version of the script takes over the handlers (or, with @remote-eval, the run scope) of the previous one
end{ "event": "end", "reason": "evicted" }Last frame, when the server ends the stream

generation identifies the version of the script’s source that produced a frame. A run that only emits does not send a generation frame itself, but its events still carry the new generation. A send or call to a rewritten script sends one even when the script registers no handler, provided the room already holds an earlier version, which any earlier send or call in that room sets up. With @remote-eval, a run that only emits can send one too.

end reasonAttach close codeCause
revoked4401 or 4403The token that opened the stream no longer admits it, or @remote-messages was removed. The frame also carries status and error. An attach socket gets a { "ok": false, … } refusal frame instead (see Revoking a token)
evicted1001The script was deleted, disabled with @enabled false, switched to the other @mode, its URL now leads to another script, or the deployment was dropped (in worker mode, also when a .env file the script reads is written, moved or deleted through the management API, or the instance’s cache is cleared; in serverless mode, also when its whole shared state is cleared or replaced)
overflow1013The client’s queue (1,000 frames or 8 MiB, frame overhead included) filled, usually because it read too slowly. One event close to 8 MiB can cause it on its own
shutdown1001The Exec service is stopping

A client that disconnects ends its own stream. The SDK and CLI do not reconnect: the iteration ends with the stream, and reconnecting is up to you. Treat revoked as final unless you have a new token. The SDK does not yield a revoked frame: the loop throws the refusal instead, as ExecRemoteTokenError for a token problem or ExecRemotePermissionError for a capability that was switched off.


The script registers its handlers with remote.on('message', fn) and remote.expose(name, fn). A handler receives the message (or the call’s arguments) followed by a context object with op, from (http or attach), token (the accepted token’s name, @token, or null), params (route parameters), generation, and signal, an AbortSignal that fires at the deadline or when the caller disconnects.

// @mode worker
// @remote-messages
// @remote-call
globalThis.paused ??= false;
remote.on('message', async (message, ctx) => {
remote.emit('received', { message, from: ctx.from });
return { paused: globalThis.paused };
});
remote.expose('pause', (reason) => {
globalThis.paused = true;
remote.emit('paused', { reason });
return true;
});
return { paused: globalThis.paused };
Terminal window
hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker send '{"type":"ping"}'
hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker call pause '"maintenance"'

The request body is a JSON object of at most 1 MiB. timeoutMs is optional and is capped by the script’s @timeout. The reply or result must be plain data (see What can be sent) and at most 8 MiB.

Event data, replies and results are converted to JSON with these rules:

  • Own enumerable data properties are sent. A toJSON method is not called, and getters are never run.
  • undefined object members are left out, and a top-level undefined is sent as null. NaN and infinite numbers become null.
  • A Date becomes an ISO string, a BigInt a string, a Map an array of [key, value] pairs and a Set an array. An Error becomes { name, message, stack }. An ArrayBuffer or typed array becomes a base64 string.
  • Functions, symbols, boxed primitives, getters and setters, proxies and circular references are refused, as is anything nested more than 256 levels deep or holding more than 1,000,000 values.

Every run of the script registers its handlers again, so a rewritten script answers with its new code. When no run of the current version has registered a handler in the room yet, the first send or call runs the script once, with metadata.method set to REMOTE_INIT, and then delivers the operation. Operations that arrive together share that one run.

  • The REMOTE_INIT run that send or call starts, and every handler call, count against @concurrent, like any other run of the script. Eval does not: it runs outside @concurrent, including the run it starts with start: true.
  • An operation that starts or joins the REMOTE_INIT run waits only until the handler it needs is registered (the message handler for send, the named function for call), not for the rest of the run. Later operations that find their handler already registered skip the wait, but still need a free @concurrent slot. Register handlers once everything they use is ready.
  • If the REMOTE_INIT run throws, it takes back the handlers it registered (not ones another run has since replaced them with): an operation still waiting for its handler gets 500 REMOTE_INIT_FAILED, and the next operation runs the script again if no handler is left. An operation that had already picked its handler, including one waiting for a @concurrent slot, still runs it.
  • A late run of an older version of the script never replaces the current version’s handlers. An operation sent to the older version while a newer one came in gets 409 REMOTE_SCRIPT_CHANGED: its handler did not run, and sending it again reaches the new version. An operation that had already picked its handler before the newer version came in, for example one waiting for a @concurrent slot, still runs the older handler.
  • An operation that reaches its deadline gets 504 REMOTE_TIMEOUT. A handler that is already running is not stopped: ctx.signal aborts, and the handler runs to its end. One still waiting for a @concurrent slot is dropped and never runs.

A handler that throws answers 422 REMOTE_THREW, with the error in details.error.


An attach socket carries events, send and call on one connection. Open a WebSocket on the script’s URL offering the subprotocol hoody-remote.v1; ?tail= works as for events. The first frame is attached, followed by the same frames an events stream gets, except that a revoked token ends the socket with a { "ok": false, … } refusal frame rather than an end frame.

To send, write a JSON text frame { "id": 1, "op": "send", "message": … } or { "id": 2, "op": "call", "name": "pause", "args": [ … ] }. The answer echoes the id: { "id": 1, "ok": true, "reply": … }, { "id": 2, "ok": true, "result": … }, or { "id": 1, "ok": false, "status": 403, "error": { … } }. eval is refused on a socket.

A socket can have 256 operations running at once (429 REMOTE_TOO_MANY_INFLIGHT beyond that) and closes with code 1009 on a frame larger than 1 MiB. Every send or call frame that passes these limits checks the script again, against its file as it is at that moment, before it runs; a frame refused earlier (not JSON, an unknown op) does not.

In the SDK, ws: true moves send, call and events onto one attach socket, and conn.attach() opens an explicit session with send, call, frames() and close(). The CLI’s attach operation prints frames to stdout and sends each stdin line. Attaching needs @remote-messages, and a @remote-token used for it must have the messages scope: a call-only token works over HTTP but cannot attach, so give it messages,call to call over the socket.

const conn = containerClient.exec.connect('bots/worker', { token: process.env.BOT_TOKEN, ws: true });

Browsers cannot set headers on a WebSocket. The token travels as a second subprotocol entry, hoody-token.<base64url of the token>, which the server reads and never echoes. The SDK does this for you.


eval runs an expression inside the scope of the script’s latest run of its current version (the most recently started run, whether it is still in flight or already finished; after an edit, run the script again or pass start: true), so it can read and change the script’s top-level variables. The latest-run target returns 409 REMOTE_SCOPE_UNAVAILABLE if the script binds or assigns to eval, uses with, or replaces the global eval; in worker mode, target: "globals" still works then, but it reaches only the globals, not the run’s local variables. It needs @remote-eval and a @remote-token whose scope includes eval; @token never unlocks it. It is always a one-shot POST, never an attach frame, and it is not held back by @concurrent.

Terminal window
hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker eval 'Object.keys(shared)'

The body takes expression, or function (a function’s source, called as fn(ctx, ...args)) with args, plus optional target, start and timeoutMs. target: "globals" evaluates in the worker context’s globals instead and needs @mode worker. With the default target, start: true runs the script once first when no run scope is available; without it, such a request gets 409 REMOTE_NO_RUN. target: "globals" needs no earlier run: it ignores start and answers with "run": null. Each room keeps only its latest finished run scope, and finished scopes are kept for at most 32 rooms per deployment, so an older one can be gone even though its script already ran. The answer is { "ok": true, "result": …, "logs": [ … ], "run": { … }, "durationMs": …, "generation": "…" }, where logs holds the console lines the evaluated code wrote. console.dir and console.table output is not included. Capture stops at 1,000 lines or 1 MiB of text; when it stops early, the answer also carries "logsTruncated": true.


Remote operations (events, send, call, attach, eval, and the REMOTE_INIT run) do not run the directory’s pre.ts or post.ts. A login check you wrote in pre.ts does not protect the remote channel: protect it with the tokens below, and check anything else inside your handlers.

The token check runs before the capability check, in this order:

  1. If the script declares any @remote-token, only those tokens admit remote operations. The script’s @token no longer does.
  2. Otherwise, the script’s @token admits events, send, call and attach.
  3. Otherwise, remote operations need no token. Anyone who can reach the container URL can use the enabled capabilities, subject to your proxy permissions.

@token and a missing token never unlock eval: it always needs a @remote-token with the eval scope.

Send the token as X-Token, Authorization: Bearer, the password of Authorization: Basic, ?token=, or a hoody-token.* subprotocol entry on attach. The SDK sends it only as X-Token or the subprotocol entry, never in a URL.

CodeStatusMeaning
REMOTE_TOKEN_REQUIRED401The script needs a token and none was sent
REMOTE_TOKEN_INVALID401The token matches none of the script’s tokens
REMOTE_TOKEN_SCOPE403The token is valid, but its scope does not cover this operation (other than eval)
REMOTE_EVAL_TOKEN_REQUIRED403eval needs a @remote-token with the eval scope; other tokens, @token and open access do not admit it
REMOTE_MESSAGES_DISABLED403The script has no @remote-messages
REMOTE_CALL_DISABLED403The script has no @remote-call

An open stream or socket is checked again on every 15-second ping, against the script as it is on disk at that moment, and when a changed version of the script starts a run, before that run’s frames are sent. Removing or rotating a token, removing messages from its scope, or removing @remote-messages therefore ends the streams that token opened within about 15 seconds, unless another current token still accepts the credentials they connected with. Removing only call or eval from its scope leaves them open. The client receives the refusal it would get if it connected now (unless the change was a .env write through the management API in worker mode, which ends the stream as evicted instead): a final { "event": "end", "reason": "revoked", "status": 401, "error": { … } } frame on an events stream, or a { "ok": false, "status": 401, "error": { … } } frame (on both, status 403 instead of 401 when the token lacks the scope or @remote-messages is gone) followed by close code 4401 or 4403 on an attach socket.

A run of an older version of the script, for example one that waited behind @concurrent, never ends a stream that the current file still admits.

The re-check applies the same token order as a new connection. Removing the last @remote-token line hands remote operations back to @token, or to no token at all when the script has none, so a stream the fallback admits stays open. To cut every client off, remove @remote-messages or replace the token with a new one.


capabilities reports what the script enables, the names and scopes of its tokens (never the secrets), the handlers and exposed functions registered by its current version, its latest run (recorded only for scripts with @remote-eval, otherwise null), and the number of open streams. For a script with no remote comment at all, the last two are left out. Disabled capabilities carry their fix.

Terminal window
hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker capabilities

If the script has @token or @remote-token lines, capabilities without a valid token answers with the token names only. A script with neither answers in full to anyone.


LimitValue
Open streams and sockets per room64 (429 REMOTE_TOO_MANY_SESSIONS for the next one)
Rooms kept per deployment256 in all. Creating a room past that drops the least recently used idle rooms; rooms in use are kept, so the total can go over 256. A dropped room loses its registered handlers, and its next send or call runs REMOTE_INIT again
Queued frames per client1,000 frames or 8 MiB, then overflow
Event name, function name128 characters
Exposed functions per room1,024 names; exposing another new name throws a RangeError (replacing an existing one is allowed)
Event data, reply, call result8 MiB serialized
send / call request body, attach frame1 MiB
Operations running on one attach socket256
Keep-alive and token re-checkevery 15 seconds

  • Scope tokens to what the client does. A dashboard that only listens needs a messages token; give call and eval to separate tokens.
  • Keep secrets in .env companions. env:NAME keeps the secret out of the script file and its history.
  • Prefer headers to ?token=. The Exec service removes a ?token= value before it logs the request, but a URL can still end up in browser history. Use the SDK, the CLI or a header wherever the client allows it.
  • Emit small frames. Each frame is serialized once and queued per client; large payloads fill slow clients’ queues first.
  • Reconnect in the client. Events emitted while a client is disconnected are lost, so fetch current state through send or call after reconnecting.

SymptomCause and fix
403 REMOTE_MESSAGES_DISABLEDAdd // @remote-messages. The error’s details.fix holds the comment and the API call
401 REMOTE_TOKEN_INVALID with a correct @tokenThe script declares a @remote-token, which replaces @token for remote operations
409 REMOTE_NO_MESSAGE_HANDLERThe script never calls remote.on('message', …)
404 REMOTE_FUNCTION_NOT_FOUNDNo remote.expose with that name; details.exposed lists the names that exist
409 REMOTE_SCRIPT_CHANGEDThe script was replaced while the operation waited, so its handler did not run. Send it again and it reaches the new version
The stream opens but no events arriveNothing emitted since the client connected, or the emitting run handled another route parameter value (another room)
A browser EventSource keeps reconnecting after endCall source.close() when a frame has event: "end"
405 REMOTE_METHODevents and capabilities are GET; send, call and eval are POST