shiftlabs Start free

Workflows

One module, one class. The run context, durable steps and sleeps, and the rules that keep a run correct.

The entry

workflow<Payload, Result>(fn) takes the type of the webhook payload and of the value fn returns. fn receives the run context.

refund-check
import { workflow } from "shiftlabs";

export default workflow<{ id: string }>(async ({ payload, step, secrets, log }) => {
  const charge = await step.run("fetch charge", () =>
    fetch(`https://api.stripe.com/v1/charges/${payload.id}`, {
      headers: { authorization: `Bearer ${secrets.STRIPE_KEY}` },
    }).then((r) => r.json()),
  );
  log.info("charge loaded", { id: charge.id });
  await step.sleep("cool-off", "10 minutes");
  await step.run(
    "notify",
    { retries: { limit: 3, delay: "30 seconds", backoff: "exponential" } },
    () =>
      fetch("https://hooks.slack.com/services/T0/B0/x", {
        method: "POST",
        body: JSON.stringify({ text: charge.id }),
      }),
  );
});

In TypeScript the module's default export must be workflow(…); in Python, exactly one function is decorated with @workflow. A deploy without one fails with bundle failed and a hint. The deploy answer also lists the secret names the code reads, and warns about any that are not set.

You do not bundle the SDK yourself: every deploy is bundled with the shiftlabs module, so the import above is all a workflow needs. fetch is the global one; every request it makes goes through the egress gateway, which swaps stand-ins and applies connections, limits and attribution.

Python packages

List packages at the top of the file, the standard way for a single-file script. We build them once, when you deploy, never when a run starts.

pythonrefund_check.py
# /// script
# dependencies = ["httpx", "numpy>=2"]
# ///
import httpx
import numpy as np
from shiftlabs import workflow


@workflow
async def refund_check(ctx):
    async def fetch_charge():
        async with httpx.AsyncClient() as http:
            r = await http.get(f"https://api.stripe.com/v1/charges/{ctx.payload['id']}",
                               headers={"authorization": f"Bearer {ctx.secrets.STRIPE_KEY}"})
            return r.json()

    charge = await ctx.step.run("fetch charge", fetch_charge)
    return {"cents": int(np.round(charge["amount"]))}

A set of packages anyone has built before deploys at once, and the answer lists the exact versions under packages. A new set answers 202 with a deployId: the version goes live when the build finishes, usually within a minute, and GET /v1/deploys/:id shows building, live or failed with the reason.

Pure-Python packages work as published. Compiled ones, such as numpy, need a build for our Python runtime; without one the deploy fails and says why. Every version keeps the packages it was deployed with; deploy with ?upgrade=1 to resolve the newest versions again. Use async HTTP clients such as httpx's AsyncClient.

Run context

Your function receives one object. Destructure what you need.

Durable steps

A duration is a number of milliseconds or a string such as "30 seconds" or "10 minutes", with units from second to year.

7d10ce5b · refund-check

Completed
Version
1d9a281e · rev A
Dispatches
2
Duration
10 min 2 s
  1. queued21:30:14.227
  2. started989 ms21:30:15.216
  3. fetch charge212 ms21:30:15.301 · result stored
  4. cool-off10 min21:30:15.514
  5. notify95 ms21:40:15.902 · same version
  6. completed21:40:16.010

Rules that keep runs correct

  • Code outside step.run executes again every time the run resumes, after a sleep or a retry. Only step results are durable, so side effects belong inside steps.
  • Every run is pinned to the version that was current when its webhook arrived. Redeploying a workflow never changes the code under a run that has already started.
  • A run may be dispatched by the engine at most 50 times and live at most 168 hours. Each invocation gets 2,000 ms of CPU and 50 subrequests. The full list is under Limits.