JS Patterns & Examples
Practical patterns for building JS components — activities, workflows, and webhooks. For API reference see JS Activities, JS Workflows, and JS Webhooks. For WIT types and JSON encoding see WIT reference.
Ready made activities
Repository obeli-sk/components contains ready-made
components that are published to Docker Hub. They can be added to a deployment.toml either by
copying obelisk-oci.toml from their respective folder or by using obelisk component add
CLI.
Activities: Variant errors — permanent vs retryable
An activity's return_type can use a variant for the error arm instead of a plain string. Any
variant case whose name contains the word permanent is treated as a non-retryable failure —
Obelisk finishes the execution immediately without consuming retries. All other cases are retried up
to max_retries. The execution-failed case is reserved by Obelisk for trap/timeout escalation and
must always be present.
[[activity_js]]
name = "fetch_dev_deps"
location = "${DEPLOYMENT_DIR}/activity/fetch_dev_deps.js"
ffqn = "myorg:monitor/repos.fetch-dev-deps" # Every function must have Function Fully Qualified Name
params = [{ name = "repo", type = "string" }]
return_type = "result<string, variant { permanent-not-found, transient-error(string), execution-failed }>"
[[activity_js.allowed_host]]
pattern = "https://raw.githubusercontent.com"
methods = ["GET"]
Throw a snake_case string to select a no-payload case; throw { case_name: payload } for a case
with payload:
// activity/fetch_dev_deps.js
export default async function fetch_dev_deps(repo) {
const branches = ["main", "master"];
for (const branch of branches) {
const url = `https://raw.githubusercontent.com/myorg/${repo}/${branch}/dev-deps.txt`;
const resp = await fetch(url, { headers: { "user-agent": "my-monitor" } });
if (resp.ok) return await resp.text();
if (resp.status !== 404) {
const msg = `HTTP ${resp.status} fetching ${url}`;
throw { transient_error: msg }; // retried; payload = diagnostic string
}
}
throw "permanent_not_found"; // not retried; no payload
}
A workflow calling this activity receives the variant as a thrown error. An imported call throws the
err payload; obelisk.getResult throws "permanent_not_found" or
{ transient_error: "HTTP 503 …" }.
Workflows: Parallel fan-out with named join sets
Create one named join set per item so the execution log labels each child by its input. Submit
all children, then drain in submission order via joinNext().
// workflow/monitor.js
import { listRepos } from "myorg:monitor/repos";
import { fetchDevDepsSubmit, fetchDevDepsAwaitNext } from "myorg:monitor-obelisk-ext/repos";
export default function run() {
const repos = listRepos();
// Submit one fetch per repo. Named join sets make the event log
// self-describing (the WebUI shows "join-set: my-repo" instead of an
// opaque generated id). Join set names allow only [A-Za-z0-9\-\/].
const perRepo = [];
for (const repo of repos) {
const js = obelisk.createJoinSet({ name: sanitizeJoinSetName(repo) });
fetchDevDepsSubmit(js, repo);
perRepo.push({ repo, js });
}
// `awaitNext` drains the join set - ordered based on responses.
const versions = [];
for (const { repo, js } of perRepo) {
const [id, result] = fetchDevDepsAwaitNext(js);
// result is { val: <string> } | { err: <variant> }
if (result && "val" in result) {
const version = parseVersion(result.val);
if (version !== null) versions.push([repo, version]);
} else {
console.warn("fetch failed for", repo);
}
}
versions.sort((a, b) => a[0].localeCompare(b[0]));
return versions;
}
function sanitizeJoinSetName(s) {
return s.replace(/[^A-Za-z0-9\-\/]/g, "-");
}
Join set name constraints:
- Allowed characters:
A-Z,a-z,0-9,-,/ - Must be unique within the workflow execution (reuse →
conflicterror) - Omit
{ name }to get an auto-generated name
For simpler cases where every child maps to an execution ID, index results via the returned ID:
const js = obelisk.createJoinSet();
const idToInput = {};
for (const item of items) {
const execId = js.submit("ns:pkg/iface.process", [item]);
idToInput[execId] = item;
}
for (let i = 0; i < items.length; i++) {
const response = js.joinNext();
if (!response.ok) continue;
const result = obelisk.getResult(response.id); // returns ok value, throws err value
const item = idToInput[response.id];
}Workflows: Saga pattern — crash-safe compensation
For the reusable parent/child lifecycle pattern, including an operator teardown race and child workflow shutdown semantics, see Cleanup supervisor (saga).
Wrap the main action in try/catch, capture any error, run the compensation unconditionally, then
re-throw. The compensation always runs because Obelisk replays the execution log on restart and
continues from the last completed step.
Keep the outer saga workflow minimal — move complex logic into a child workflow so a bug in complex logic fails the child (catchable), not the outer saga itself:
// workflow/run.js — outer saga; kept intentionally simple
import { agent } from "demo:fly-agent/workflow";
import * as apps from "obelisk-flyio:activity-fly-http/apps@1.0.0-beta";
export default function run(app_name, org_slug, prompt) {
let result = null,
error = null;
// Execute inner workflow; capture failure so cleanup still runs.
try {
result = agent(app_name, org_slug, prompt);
} catch (e) {
error = String(e);
console.log(`Agent workflow failed: ${error}`);
}
// Compensation: always delete the app — even if the server crashed between
// the try/catch above and here, Obelisk replays from this point on restart.
try {
apps.delete(app_name, true);
} catch (e) {
console.log(`Cleanup failed (manual action may be needed): ${e}`);
}
if (error !== null) throw error;
return result;
}
The try/catch branch taken is recorded in the execution log — on replay, Obelisk always follows
the same branch.
Workflows Polling external state
Use a bounded for loop with obelisk.sleep between probes. The sleep position is durable: a
server crash during the loop resumes from the last completed step on restart.
// workflow/agent.js — inner workflow
import * as machines from "obelisk-flyio:activity-fly-http/machines@1.0.0-beta";
export default function agent(app_name, org_slug, prompt) {
// ... create VM ...
// Poll until the VM is in the 'started' state.
let started = false;
for (let i = 0; i < 20; i++) {
const machine = machines.get(app_name, machine_id);
if (machine !== null && machine.state === "started") {
started = true;
break;
}
console.log(`VM state: ${machine ? machine.state : "unknown"}, retrying in 3s`);
obelisk.sleep({ seconds: 3 });
}
if (!started) throw "VM did not reach 'started' state within timeout";
// Poll until the result file appears.
let output = null;
for (let i = 0; i < 30; i++) {
const cat = machines.exec(app_name, machine_id, ["cat", "/result.txt"], {
timeout_secs: 10,
stdin: null,
});
if (cat.exit_code === 0) {
output = (cat.stdout || "").trim();
break;
}
console.log(`Result not ready yet (attempt ${i + 1}/30)`);
obelisk.sleep({ seconds: 5 });
}
if (output === null) throw "Agent did not produce a result within timeout";
return output;
}
Keep the activity probe cheap and idempotent. Always set a hard loop ceiling and an explicit timeout error so the workflow terminates rather than looping forever.
Webhooks: Querying past executions
A webhook can query the Obelisk Web API directly via fetch to render the result of a previously
completed workflow run. The API is on :5005 by default; inject the URL via env_vars so it is
configurable:
[[webhook_endpoint_js]]
name = "show"
location = "${DEPLOYMENT_DIR}/webhook/show.js"
routes = [{ methods = ["GET"], route = "/" }]
env_vars = [{ key = "OBELISK_API_URL", value = "${OBELISK_API_URL:-http://127.0.0.1:5005}" }]
[[webhook_endpoint_js.allowed_host]]
pattern = "${OBELISK_API_URL:-http://127.0.0.1:5005}"
methods = ["GET"]// webhook/show.js
const WORKFLOW_FFQN = "myorg:monitor/monitor.run";
export default async function handle(_request) {
const apiBase = process.env["OBELISK_API_URL"];
// List recent executions, newest first (default). Find the latest finished one.
const listResp = await fetch(
`${apiBase}/v1/executions?ffqn_prefix=${encodeURIComponent(WORKFLOW_FFQN)}&length=50`,
{ headers: { accept: "application/json" } },
);
if (!listResp.ok) {
return new Response(`Failed to list executions: HTTP ${listResp.status}`, {
status: 502,
});
}
const executions = await listResp.json();
const finished = executions.find((e) => e.pending_state?.status === "finished");
if (!finished) {
return new Response("No finished execution found.", { status: 200 });
}
// Fetch the return value.
const retResp = await fetch(
`${apiBase}/v1/executions/${encodeURIComponent(finished.execution_id)}`,
{ headers: { accept: "application/json" } },
);
if (!retResp.ok) {
return new Response(`Failed to fetch result: HTTP ${retResp.status}`, {
status: 502,
});
}
// RetVal shape: { ok: <value> } | { err: <value> } | { execution_failure: ... }
const retVal = await retResp.json();
if ("ok" in retVal) {
return Response.json(retVal.ok);
} else if ("err" in retVal) {
return new Response(`Workflow error: ${JSON.stringify(retVal.err)}`, {
status: 200,
});
} else {
return new Response(`Execution failure: ${JSON.stringify(retVal.execution_failure)}`, {
status: 200,
});
}
}
pending_state.status values: locked, pending_at, blocked_by_join_set, paused, finished.
Pass ?follow=true to GET /v1/executions/{id} instead of 425 Too Early while still running. See
Programmatic access for the full API.
Workflow import API (preferred)
| Import pattern | Description |
|---|---|
import { fn } from "ns:pkg/ifc" | Call and await a child execution (returns ok, throws err) |
import * as ns from "ns:pkg/ifc" | Namespace import for all functions in an interface |
import { fnSubmit, fnAwaitNext } from "ns:pkg-obelisk-ext/ifc" | Submit to a join set and await next result (typed) |
import { fnSchedule } from "ns:pkg-obelisk-schedule/ifc" | Schedule a fire-and-forget execution |
import { fnStub } from "ns:pkg-obelisk-stub/ifc" | Inject a result for a stub activity |
Function names are converted from kebab-case (WIT) to camelCase (JS): get-temperature becomes
getTemperature.
Workflow obelisk.* API (dynamic)
Use these when the FFQN cannot be specified statically:
| Call | Returns | Description |
|---|---|---|
obelisk.call(ffqn, argArray) | returns ok value, throws error value | Call and await a child execution |
obelisk.executionIdGenerate() | executionId (string) | Generate a unique execution ID |
obelisk.executionIdCurrent() | executionId (string) | Get the execution ID of the current workflow |
obelisk.schedule(execId, ffqn, argArray) | executionId (string) | Schedule a top-level execution immediately |
obelisk.schedule(execId, ffqn, [arg1], { seconds: 1 }) | executionId (string) | Delay is one of: milliseconds/seconds/minutes/hours/days |
obelisk.sleep(duration) | Date object | Persistent blocking sleep, duration: {milliseconds/seconds/minutes/hours/days: int} |
obelisk.stub(execId, result) | Supplies a result to a stub activity execution | |
obelisk.randomU64(min, max) | u64 | |
obelisk.randomString(minLen, maxLen) | string |
Workflow join set API (dynamic)
Use these when the FFQN cannot be specified statically (otherwise prefer extension imports):
| Call | Returns | Description |
|---|---|---|
let js = obelisk.createJoinSet() | join set object | Create a new join set (optionally { name: "…" }) |
js.submit(ffqn, argArray) | childExecId (string) | Submit a child execution without blocking |
js.submitDelay(duration) | delayId (string) | Submit a timer, duration: {milliseconds/seconds/minutes/hours/days: int} |
js.joinNext() | { ok: bool, id: childExecId / delayId, type: "execution"/"delay" } | Block until the next result in this join set |
js.joinNextTry() | ok value (throws err value), undefined if pending, throws JoinSetExhaustedError if exhausted | Non-blocking: attempt to get next result without waiting |
js.lastId | string | ID of the last child completed via joinNext* or awaitNext |
js.close() | Cancel activities and delays, await child workflows | |
obelisk.getResult(childExecId) | ok value (throws err value) | Fetch a result after child execution was submitted and consumed with joinNext* |
Webhook import API (preferred)
| Import pattern | Description |
|---|---|
import { fn } from "ns:pkg/ifc" | Call and await a child execution |
import { fnSchedule } from "ns:pkg-obelisk-schedule/ifc" | Schedule a fire-and-forget execution |
Webhook obelisk.* API (dynamic)
Use these when the FFQN cannot be specified statically:
| Call | Returns | Description |
|---|---|---|
obelisk.call(ffqn, argArray) | returns ok value, throws error value | Call and await a child execution |
obelisk.executionIdGenerate() | executionId (string) | Generate a unique execution ID |
obelisk.executionIdCurrent() | executionId (string) | Get the execution ID of the current webhook invocation |
obelisk.schedule(execId, ffqn, argArray) | executionId (string) | Schedule a top-level execution immediately |
obelisk.schedule(execId, ffqn, argArray, { seconds: 1 }) | executionId (string) | Delay is one of: milliseconds/seconds/minutes/hours/days |
obelisk.getStatus(execId) | { status: "pendingAt"/"locked"/"paused"/"blockedByJoinSet"/"finished", finishedStatus:"ok"/"err"/"executionFailure" } | |
obelisk.get(execId) | ok value (throws err value) | Blocking: waits until execution finishes |
obelisk.tryGet(execId) | ok value (throws err value), undefined if not finished yet | Non-blocking result fetch |
Webhooks also have fetch, Response, Request, process.env, crypto.subtle — same as
JS Activities.
Structured concurrency on join set close
When a join set is closed (via js.close() or implicitly when the workflow returns or throws):
- Unpolled child activities and delays are cancelled.
- Unpolled child workflows are awaited (not cancelled).
obelisk.call creates and closes a join set internally — the same rules apply. If you submit to a
join set and return without draining it, any pending activities are cancelled but pending
sub-workflows continue running. To wait for them, drain the join set before returning.
Running an Obelisk application
Verify before starting
Check that the deployment configuration is valid — compiles all JS components and resolves all types — without requiring real credentials:
obelisk server verify --ignore-missing-env-vars -d deployment.tomlStart the server
obelisk server run --deployment deployment.toml
After the first run the deployment is persisted to the database; subsequent restarts (e.g. after a
crash) can omit --deployment:
obelisk server runSubmit a one-off execution and follow it
obelisk execution submit my-namespace:my-pkg/my-iface.my-fn '["arg1", 42]'
# prints: E_01KN...
Execution IDs are E_-prefixed ULIDs (e.g. E_01KN2X...).
Block until finished and print the result:
obelisk execution submit --follow my-namespace:my-pkg/my-iface.my-fn '[]'
Or follow a previously submitted execution by ID:
obelisk execution status --follow E_01KN2X...
obelisk execution result --follow E_01KN2X...
Use status when you want the current lifecycle state, and result when you want the final return
value once the execution finishes.
Common pitfalls
- Workflows cannot use
fetch. Push all I/O (HTTP, file, database) into activities. max_retries=0does not mean idempotency is optional. A server restart before an activity finishes will retry it regardless. Usepermanent-*variant cases to disable retry on specific errors without losing retries for transient ones.Math.random()andDate.now()are deterministic in workflows. Their values are recorded in the execution log on first call and replayed identically on crash-recovery.- Join set names must be unique per execution. Derive them from stable inputs (IDs, keys) and
sanitize to
[A-Za-z0-9\-\/]. 425 Too EarlyfromGET /v1/executions/{id}means the execution is still running; use?follow=trueto long-poll, or pollgetStatus/tryGetfrom the webhook side.- Component and cron names must use only
[a-zA-Z0-9_]— hyphens are not allowed. Use underscores instead (e.g.daily_monitor, notdaily-monitor). - Secret env vars must not duplicate a key from the component's
env_vars. A variable declared in[allowed_host.secrets] env_varsmust not also appear in the component-levelenv_vars; the runtime injects it automatically and will reject the configuration with a "collides with anenv_varsentry" error otherwise.