Quickstart
Submit a durable task from one process, complete it from a worker in another, and read back its recorded result, in TypeScript or Python.
In this quickstart you run two programs against the hosted service:
- a producer that opens a run, submits one task to a work queue, and waits for the result;
- a worker that takes tasks from that queue and completes them.
The producer and worker can be in different languages. Pick a tab once and every code sample on the site follows it.
1. Get a space and an API key
Section titled “1. Get a space and an API key”runstate is in a private developer preview. Once you have access:
- Sign in to the console at app.getrunstate.com and open a space (a project or environment; the API calls it a space). Copy its space id.
- Open API keys in that space and create a key. The secret is shown once, so copy it now.
No access yet? Request preview access.
2. Install the SDK
Section titled “2. Install the SDK”Node.js 22 or later.
mkdir runstate-quickstart && cd runstate-quickstartnpm init -ynpm pkg set type=modulenpm install runstate-sdknpm install --save-dev tsxPython 3.10 or later. The package installs as runstate-sdk and imports as runstate.
mkdir runstate-quickstart && cd runstate-quickstartpython -m venv .venv && source .venv/bin/activatepip install runstate-sdk3. Point the SDK at the hosted API
Section titled “3. Point the SDK at the hosted API”Both SDKs read three environment variables. Set them in every terminal you use below:
export RUNSTATE_BASE_URL=https://api.getrunstate.comexport RUNSTATE_SPACE_ID=<your space id>export RUNSTATE_API_KEY=<your api key>4. Write the producer
Section titled “4. Write the producer”The producer makes sure a work queue named work exists, opens a run, submits one task, and waits up to 60 seconds for its result.
import { Runstate } from 'runstate-sdk';
const rs = new Runstate({ holder: 'quickstart-producer' });
// Create the work queue if it doesn't exist yet (safe to call every time).await rs.mailboxes.ensure('work', { mode: 'WORK' });
// A run groups everything this swarm does, so you can cancel it as one unit.const run = await rs.scopes.create();console.log(`RUNSTATE_SCOPE_ID=${run.id}`);
// Submit a task. The key identifies the logical task: submitting the same// key with the same input again returns this task instead of a new one.const ticket = await run.mailbox('work').submit( { greeting: 'runstate', n: 21 }, { key: `quickstart-task-${Date.now()}` },);console.log(`task ${ticket.id} submitted, waiting for a worker...`);
// Poll until the task has a recorded result.const outcome = await ticket.result({ timeoutMs: 60_000 });console.log(outcome.state, outcome.outcome);import asyncioimport time
from runstate import AsyncRunstate
async def main() -> None: async with AsyncRunstate(holder="quickstart-producer") as rs: # Create the work queue if it doesn't exist yet (safe to call every time). await rs.mailboxes.ensure("work", mode="WORK")
# A run groups everything this swarm does, so you can cancel it as one unit. run = await rs.scopes.create() print(f"RUNSTATE_SCOPE_ID={run.id}", flush=True)
# Submit a task. The key identifies the logical task: submitting the same # key with the same input again returns this task instead of a new one. ticket = await run.mailbox("work").submit( {"greeting": "runstate", "n": 21}, key=f"quickstart-task-{int(time.time())}", ) print(f"task {ticket.id} submitted, waiting for a worker...", flush=True)
# Poll until the task has a recorded result. outcome = await ticket.result(timeout_ms=60_000) print(outcome["state"], outcome["outcome"])
asyncio.run(main())5. Write the worker
Section titled “5. Write the worker”The worker attaches to the producer’s run and consumes the work queue. consume() keeps the task’s lease alive while your handler runs, completes the delivery when the handler returns, and puts it back on the queue if the handler throws.
import { Runstate } from 'runstate-sdk';
const runId = process.env.RUNSTATE_SCOPE_ID;if (!runId) throw new Error('set RUNSTATE_SCOPE_ID to the id the producer printed');
const rs = new Runstate({ holder: 'quickstart-worker' });const queue = rs.scope(runId).mailbox('work');console.log(`worker listening on run ${runId}`);
// Runs until the process is stopped.await queue.consume<{ greeting: string; n: number }>(async (data, delivery) => { // Completing with a payload records it as the task's result. await delivery.complete({ payload: { doubled: data.n * 2 } }); console.log(`completed ${delivery.id} (attempt ${delivery.attempt})`);});import asyncioimport os
from runstate import AsyncRunstate
async def main() -> None: run_id = os.environ["RUNSTATE_SCOPE_ID"] # the id the producer printed async with AsyncRunstate(holder="quickstart-worker") as rs: queue = rs.scope(run_id).mailbox("work") print(f"worker listening on run {run_id}", flush=True)
async def handle(data, delivery): # Completing with a payload records it as the task's result. await delivery.complete(result={"payload": {"doubled": data["n"] * 2}}) print(f"completed {delivery.id} (attempt {delivery.attempt})", flush=True)
# Runs until the process is stopped. await queue.consume(handle)
asyncio.run(main())6. Run it
Section titled “6. Run it”Start the producer in one terminal. It prints the run id, then waits:
npx tsx producer.tspython producer.pyWithin 60 seconds, start the worker in a second terminal with that id:
RUNSTATE_SCOPE_ID=<id printed by the producer> npx tsx worker.tsRUNSTATE_SCOPE_ID=<id printed by the producer> python worker.pyThe worker completes the task and the producer prints:
SUCCEEDED { doubled: 42 }(Python prints SUCCEEDED {'doubled': 42}.) Stop the worker with Ctrl-C.
The two sides are interchangeable: a TypeScript producer works with a Python worker and the other way round, because both talk to the same API.
What just happened
Section titled “What just happened”- The run (
rs.scopes.create()) is the unit you cancel to stop new work. Workers only receive tasks submitted under the run they attach to, which is why the worker needs the run id. - The task is durable. If the producer crashed while waiting, it could re-attach later with
rs.scope(runId).task(taskId)and callresult()again. The recorded result is retrieved, not recomputed. - The lease. The worker held the task under a lease that
consume()renewed while the handler ran. Had the worker died mid-task, the lease would have expired and another worker would have received the same task, withattemptincremented. A late completion from the dead worker is rejected. - The result is exactly one recorded outcome per task. The work itself can run more than once if a worker crashes after doing it but before completing, so make side effects safe to repeat.
Next steps
Section titled “Next steps”- Concepts: the vocabulary behind runs, work queues, tasks and leases.
- Claims, shared tasks and takeover: ownership of keys, shared tasks, and crash takeover in detail.
- Configuration & auth: every client option, key permissions and worker identity.