Python SDK reference
The shiftlabs module is what a Python workflow imports. Every deploy includes it, and the shiftlabs package on PyPI gives your editor its type hints and adds the testing kit. This page is generated from its Python source.
Duration
Milliseconds, or a string such as "30 seconds" with units from second to year, singular or plural.
Duration: TypeAlias = int | float | str
FatalError
Raised in a step, fails the step and the run at once, with no more attempts. Raised outside a step, it fails the run as any error does.
class FatalError(Exception): ...
Retries
step.run(..., retries=...) and step.exec(..., retries=...): how many times to retry a failing step (a FatalError in step.run never is), the duration between attempts, and the backoff (exponential by default).
class Retries(TypedDict):
limit: int
delay: Duration
backoff: NotRequired[Literal["constant", "linear", "exponential"]]
PayloadSchema
@workflow(payload=...): a pydantic model or a JSON Schema dict.
PayloadSchema: TypeAlias = type[_PayloadModel] | dict[str, Any]
Concurrency
@workflow(concurrency=...): at most limit runs of the workflow at once, from 1 to the workspace's concurrent runs. With key, a dotted path into the payload such as "customer.id", runs whose payloads hold the same value there share the limit, and a run without the field uses the key "". Runs past the limit wait in arrival order with the status queued, and start as runs with the same key end.
class Concurrency(TypedDict):
limit: int
key: NotRequired[str]
WorkflowDefinition
What @workflow returns. A workflow module defines exactly one; shiftlabs.testing.run_workflow takes it.
class WorkflowDefinition:
fn: Callable[[Context], Awaitable[Any]]
payload: PayloadSchema | None
concurrency: Concurrency | None
workflow
Marks the module's entry: @workflow async def refund_check(ctx): …. @workflow(payload=Invoice) takes the payload's schema, a pydantic model or a JSON Schema dict: webhooks and API runs whose payload fails it get 400. @workflow(concurrency={"limit": 1, "key": "customer_id"}) caps the runs at once; runs past it wait.
@overload
def workflow(
fn: Callable[[Context], Awaitable[Any]],
/,
) -> WorkflowDefinition: ...
@overload
def workflow(
*,
payload: PayloadSchema | None = None,
concurrency: Concurrency | None = None,
) -> Callable[[Callable[[Context], Awaitable[Any]]], WorkflowDefinition]: ...
ApprovalDecision
What step.wait_for_approval returns. by: the approver's email or API key id, None when nobody answered in time; note: what the approver wrote.
class ApprovalDecision(TypedDict):
approved: bool
by: str | None
note: str | None
SecretRef
{"secret": NAME} in step.exec's env or files: a secret marked readable, whose value the runner gets for that command only.
class SecretRef(TypedDict):
secret: str
ExecResult
What step.exec returns, and ExecError's result: the exit code (124 when the command timed out), the last 256 KiB of stdout and stderr with secret values replaced by "[redacted]", each stream's whole size in bytes, truncated when stdout or stderr holds only the end of its stream, timed_out, how long it ran in milliseconds, the name of the runner that ran it, and which try this was, 1 for the first.
class ExecResult(TypedDict):
exit_code: int
stdout: str
stderr: str
stdout_bytes: int
stderr_bytes: int
truncated: bool
timed_out: bool
duration_ms: int
runner: str
attempt: int
ExecError
Raised by step.exec when the command exits with a code outside ok_exit_codes, or times out. result holds its exit code and output.
class ExecError(Exception):
result: ExecResult
Step
Code outside step.run() runs again every time the run resumes (after a sleep or a retry); only step results are durable, so side effects belong inside steps.
async def run(name: str, fn: Callable[[*Ts], Awaitable[T]], *args: *Ts, retries: Retries | None = None, timeout: Duration | None = None) -> T
async def run(name: str, fn: Callable[[*Ts], T], *args: *Ts, retries: Retries | None = None, timeout: Duration | None = None) -> TRuns
fn(*args)once and stores its JSON-serializable result; a resumed run gets the stored result.async def sleep(name: str, duration: Duration) -> NoneDurable sleep. The run resumes after
duration, on the same version.async def sleep_until(name: str, until: datetime.datetime | int | float) -> NoneDurable sleep until a datetime or epoch seconds.
async def ask(name: str, *, agent: str, message: str, schema: None = None) -> str
async def ask(name: str, *, agent: str, message: str, schema: dict[str, Any]) -> AnyAsks
agentand returns its final answer: the text, or withschema(a JSON Schema dict) the JSON that matches it. A run has one conversation per step, which the workflow owns; a resumed run gets the stored answer. An answer that doesn't match gets one more turn; a second miss fails the step, with no retries.async def start(name: str, *, workflow: str, payload: object = None) -> strStarts a run of another workflow in this workspace and returns its run id, without waiting. The id comes from this run and
name, so a resumed run never starts a second one. It counts toward your limits like any run.async def call(name: str, *, workflow: str, payload: object = None) -> AnyStarts a run like start(), waits for it to end and returns its result; a failed run fails this step with its error. The wait counts toward the run's lifetime, not its steps, and the run sleeps while it waits.
async def exec(name: str, *, run: str | None = None, command: list[str] | None = None, env: Mapping[str, str | SecretRef] | None = None, files: Mapping[str, str | SecretRef] | None = None, stdin: str | None = None, timeout: Duration | None = None, runner: str | None = None, ok_exit_codes: list[int] | Literal["any"] | None = None, retries: Retries | None = None) -> ExecResultRuns a command on a runner in your own network and returns its result:
run, a bash script, orcommand, a program and its arguments.envandfiles(by relative path) take text, or {"secret": NAME} for a secret marked readable, andstdingoes to the command's standard input.timeoutdefaults to "10 minutes",runnerto the pool "default" andok_exit_codesto [0] ("any" accepts every exit code);retriestries again after a failure. Another exit code raises ExecError with the result, and a command no runner ran or finished raises an error that says why. A resumed run gets the stored result.async def wait_for_event(name: str, *, timeout: Duration | None = None) -> AnyWaits for the event
name(sent toevent_url(name)orPOST /v1/runs/:id/events/:name) and returns its data, the parsed JSON body or the text, or None oncetimeout(default "24 hours") passes. An event sent before the run waits is kept. Waiting counts against the run's lifetime, not its steps.def event_url(name: str) -> strA URL that sends this run the event
namewhen POSTed to, with no API key: it reaches this run and event only.def idempotency_key(name: str) -> strsl_and 32 hex characters from this run's id andname: the same on every retry, replay and resume of the run, different in every other run. Pass it asIdempotency-Keyto Stripe and similar APIs. Not a step.async def wait_for_approval(name: str, *, message: str | None = None, timeout: Duration | None = None, approvers: list[str] | None = None, approvals: int | None = None) -> ApprovalDecisionPuts a request in the workspace's Approvals inbox, notifies its approvers, and waits for one to approve or deny. Unanswered by
timeout(default "72 hours") it counts as denied. Waiting counts against the run's lifetime, not its steps. Names can't end likerefund #2.approvers: up to 20 of "role:admin", "role:manager" or member emails; only they see and answer it. Default: every admin.approvals: how many approvers must approve, 1 to 5 (default 1). The workspace's setting in Settings → Approvals is the least a request needs; asking for fewer has no effect. Under 2 or more, each approval comes from a different member; an API key can't approve.
Secrets
ctx.secrets.NAME: a stand-in for each secret with hosts, which the gateway swaps for the value on requests to those hosts. An unknown name raises KeyError.
class Secrets: ...
Vars
ctx.vars.NAME: the workspace value, overridden per workflow. A name that is not set reads as None.
class Vars: ...
Log
ctx.log.info(message, fields), and debug, warn, error: one log line each, fields as JSON after the message.
def debug(message: object, fields: Mapping[str, object] | None = None) -> NoneA line at level
debug.def info(message: object, fields: Mapping[str, object] | None = None) -> NoneA line at level
log.def warn(message: object, fields: Mapping[str, object] | None = None) -> NoneA line at level
warn.def error(message: object, fields: Mapping[str, object] | None = None) -> NoneA line at level
error.
State
ctx.state: JSON values kept per workflow, shared by all its runs and versions; the last write wins. Every method works only inside step.run() and raises outside one. A key is 1 to 512 bytes. Writes past the plan's limits (keys per workflow, value size, total) raise.
async def get(key: str) -> AnyThe value stored at key, or None if there is none.
async def set(key: str, value: Any) -> NoneStores value as JSON at key, replacing what was there.
async def delete(key: str) -> NoneRemoves key. Removing a key that isn't there does nothing.
async def list(prefix: str | None = None, after: str | None = None, limit: int | None = None) -> list[str]The workflow's keys in order: those starting with prefix, after
after, at most limit (1 to 1,000).
Context
The run context, the one argument of the @workflow function.
payload: AnyThe webhook body: parsed JSON when sent as application/json, the raw text otherwise.
run_id: strThis run's id.
step: StepDurable steps and sleeps.
vars: Varsvars.NAME: the workspace value, overridden per workflow.secrets: Secretssecrets.NAME: a stand-in for each secret with hosts.reveal: Callable[[str], str]reveal(name): the value of a secret marked readable. Every load is audited and the value is scrubbed from logs.sign: Callable[[str, str, Literal["hex", "base64"]], Awaitable[str]]await sign(name, data, encoding="hex"): HMAC-SHA256 ofdatawith a secret marked hmac. Returns only the signature.log: Loglog.info(message, fields), anddebug,warn,error: one log line each, shown with the run.state: Stateawait state.get(key),set,delete,list: JSON values kept per workflow across runs, only inside step.run().
Testing kit
shiftlabs.testing runs a workflow inside your test, the way the platform runs it, with no deploy. Only the PyPI package has it: deploys never include it. See Test a workflow.
StepType
The step method a RunStep was.
StepType: TypeAlias = Literal[
"run",
"sleep",
"sleep_until",
"wait_for_event",
"wait_for_approval",
"ask",
"start",
"call",
"exec",
]
ApprovalAnswer
An answer in approvals given as a dict. by defaults to "test" and note to None.
class ApprovalAnswer(TypedDict):
approved: bool
by: NotRequired[str | None]
note: NotRequired[str | None]
SecretValue
A secret in secrets with the flags PUT /v1/secrets/:NAME takes. Both are False unless given, as there: readable lets reveal(NAME) return the value, and hmac lets sign(NAME, …) sign with it.
class SecretValue(TypedDict):
value: str
readable: NotRequired[bool]
hmac: NotRequired[bool]
ExecAnswer
A command's outcome in execs. stdout and stderr default to "". A command the runner stopped at its timeout exits with 124, so with timed_out True exit_code is 124 or left out.
class ExecAnswer(TypedDict):
exit_code: NotRequired[int]
stdout: NotRequired[str]
stderr: NotRequired[str]
timed_out: NotRequired[bool]
ExecRequest
What a function in execs gets: the step's name and its options as the platform checked them, with their defaults, None for run or command, and timeout and the retries' delay in milliseconds.
class ExecRequest(TypedDict):
name: str
run: str | None
command: list[str] | None
env: dict[str, str | shiftlabs.SecretRef]
files: dict[str, str | shiftlabs.SecretRef]
stdin: str | None
timeout: int
runner: str
ok_exit_codes: list[int] | Literal["any"]
retries: shiftlabs.Retries | None
RunStep
One step the run took. attempts: the attempts step.run or step.exec made, 1 when the first succeeded, and 1 for every other step. output: what it returned: a result, the event's data, the decision, the answer, a run id. error: why it failed, if it did.
@dataclasses.dataclass(frozen=True)
class RunStep:
type: StepType
name: str
attempts: int = 1
output: Any = None
error: str | None = None
LogLine
A log line, as the run's logs show it: info writes level "log".
@dataclasses.dataclass(frozen=True)
class LogLine:
level: Literal["debug", "log", "warn", "error"]
message: str
StartedRun
A run that step.start or step.call started. name: the step's name.
@dataclasses.dataclass(frozen=True)
class StartedRun:
name: str
workflow: str
payload: Any
run_id: str
RunResult
What run_workflow returns: output when the workflow returned, error when it raised. steps: every step, in the order the workflow called them. state: the workflow's state when the run ended.
@dataclasses.dataclass(frozen=True)
class RunResult:
status: Literal["completed", "failed"]
output: Any
error: Exception | None
steps: list[RunStep]
logs: list[LogLine]
state: dict[str, Any]
started: list[StartedRun]
run_workflow
Runs the @workflow function in this process as the platform runs it, in an event loop of its own, and returns what it did. Steps run inline: retries happen at once, FatalError fails a step with no more attempts, and sleeps return at once. Waits are answered by step name: events gives a wait's data (none returns None, as a timeout does), approvals True, False or {"approved", "by", "note"} (none is denied), asks the agent's answer and calls the other run's result (none fails the step), starts a run id. An exception in asks, calls or starts fails that step with its message. execs gives each step.exec's outcome, {"exit_code", "stdout", "stderr", "timed_out"}, or a function of the ExecRequest and the attempt that returns one; an exception fails the attempt as a command no runner ran does, retries apply, and a step with none fails. secrets gives each secret's value, or {"value", "readable", "hmac"}: ctx.secrets.NAME reads a stand-in shaped like the platform's, reveal needs a readable secret and sign an hmac one. payload ({} when None) must pass the workflow's payload schema, or this raises ValueError. Then the workflow runs again on its stored step results, as a resumed run does, and this raises AssertionError "code outside a step changed on replay" if it calls other steps; replay=False skips that.
def run_workflow(
definition: shiftlabs.WorkflowDefinition,
*,
payload: object = None,
secrets: Mapping[str, str | SecretValue] | None = None,
vars: Mapping[str, str] | None = None,
state: Mapping[str, object] | None = None,
events: Mapping[str, object] | None = None,
approvals: Mapping[str, bool | ApprovalAnswer] | None = None,
asks: Mapping[str, object] | None = None,
calls: Mapping[str, object] | None = None,
starts: Mapping[str, str | Exception] | None = None,
execs: Mapping[
str,
ExecAnswer | Exception | Callable[
[ExecRequest, int],
ExecAnswer | Exception | Awaitable[ExecAnswer | Exception],
],
] | None = None,
replay: bool = True,
) -> RunResult: ...