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 = "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:

// myorg:monitor/repos.fetch-dev-deps: func(repo: string) -> result<string, variant { permanent-not-found, transient-error(string), execution-failed }>
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().

// myorg:monitor/monitor.run: func() -> result<list<tuple<string, string>>, string>
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:

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:

// demo:fly-agent/workflow.run: func(app-name: string, org-slug: string, prompt: string) -> result<string, string>
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.

// demo:fly-agent/workflow.agent: func(app-name: string, org-slug: string, prompt: string) -> result<string, string>
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 = "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, cancelling, 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 patternDescription
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)

Prefer imports when the target function is known statically; imports are verified when the deployment starts. Use these dynamic calls only when the FFQN is constructed at runtime:

CallReturnsDescription
obelisk.call(ffqn, argArray)returns ok value, throws error valueCall 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 objectPersistent 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)

Prefer extension imports when the target function is known statically; imports are verified when the deployment starts. Use this dynamic join set API only when the FFQN is constructed at runtime:

CallReturnsDescription
let js = obelisk.createJoinSet()join set objectCreate 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 exhaustedNon-blocking: attempt to get next result without waiting
js.lastIdstringID of the last child completed via joinNext* or awaitNext
js.close()undefinedCancel activities, delays, and -cancellable child workflows; await non-cancellable child workflows. Does not report close failures; repeated closes are no-ops
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 patternDescription
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)

Prefer imports when the target function is known statically; imports are verified when the deployment starts. Use these dynamic calls only when the FFQN is constructed at runtime:

CallReturnsDescription
obelisk.call(ffqn, argArray)returns ok value, throws error valueCall 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"/"cancelling"/"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 yetNon-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):

An imported direct call and obelisk.call both create and close 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, cancellable sub-workflows are cancelled recursively, and non-cancellable sub-workflows continue running until they finish. To wait for them, drain the join set before returning. See Cancellation.

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.toml

Start 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 run

Submit 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