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.
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 }),
}),
);
});
import json
from workers import fetch
from shiftlabs import workflow
@workflow
async def refund_check(ctx):
async def fetch_charge():
r = await fetch(f"https://api.stripe.com/v1/charges/{ctx.payload['id']}",
headers={"authorization": f"Bearer {ctx.secrets.STRIPE_KEY}"})
return await r.json()
charge = await ctx.step.run("fetch charge", fetch_charge)
ctx.log.info("charge loaded", {"id": charge["id"]})
await ctx.step.sleep("cool-off", "10 minutes")
async def notify():
await fetch("https://hooks.slack.com/services/T0/B0/x", method="POST", body=json.dumps({"text": charge["id"]}))
await ctx.step.run("notify", notify, retries={"limit": 3, "delay": "30 seconds", "backoff": "exponential"})
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.
# /// 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.
payloadThe webhook body: parsed JSON when the request is sent as
application/json, the raw text otherwise.stepDurable steps and sleeps; see Durable steps.
varsVars as
vars.NAME: the workspace value, overridden per workflow. A name that is not set reads asundefined. See Vars.secretsA stand-in per secret with hosts, as
secrets.NAME: a string the gateway swaps for the real value on its way to those hosts. An unknown name throws. See Secrets and connections.reveal(secret)The value of a secret marked
readable. Every load is audited, and the value is scrubbed from logs.sign(secret, data, "hex" | "base64")HMAC-SHA256 of
datawith a secret markedhmac. Returns the signature only (hex by default); the secret never enters your code.loglog.info(message, fields?), anddebug,warn,error: one log line each, shown with the run.runIdThis run's id.
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.
step.run(name, fn)
step.run(name, config, fn)Runs
fnonce and stores its result. When the run resumes, the step returns the stored result instead of running again.step.sleep(name, duration)Durable sleep. The run resumes after
duration, on the same version.step.sleepUntil(name, until)Durable sleep until a
Dateor an epoch-milliseconds number.
7d10ce5b · refund-check
Completed- Version
1d9a281e· rev A- Dispatches
- 2
- Duration
- 10 min 2 s
- queued21:30:14.227
- started989 ms21:30:15.216
- fetch charge212 ms21:30:15.301 · result stored
- cool-off10 min21:30:15.514
- notify95 ms21:40:15.902 · same version
- completed21:40:16.010
retries.limitHow many times to retry a failing step.
retries.delayA duration between attempts.
retries.backoff"constant","linear"or"exponential".timeoutA duration after which an attempt fails.
Rules that keep runs correct
- Code outside
step.runexecutes 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.