Context API
Every service handler receives a context object (ctx) that provides methods to access task data and control execution flow.
Data Access
Section titled “Data Access”ctx.input
Section titled “ctx.input”The input data for the current message. This is the payload that was routed to this service node based on the task template graph.
createService('1.0.0', (ctx) => { const input = ctx.input // input is the data routed to this service node})def handler(ctx): input = ctx.input # input is the data routed to this service nodectx.configuration
Section titled “ctx.configuration”The static configuration set on the service node in the task template editor.
createService('1.0.0', (ctx) => { const config = ctx.configuration // config is the JSON set in the UI for this service node})def handler(ctx): config = ctx.configuration # config is the dict set in the UI for this service nodectx.taskId
Section titled “ctx.taskId”The unique identifier of the current task execution.
ctx.debug
Section titled “ctx.debug”A logger that sends messages to the Requence UI in real time. Available methods: log, info, warn, error.
createService('1.0.0', (ctx) => { ctx.debug.log('Processing started') ctx.debug.warn('Something looks off')})def handler(ctx): ctx.debug.log("Processing started") ctx.debug.warn("Something looks off")Flow Control
Section titled “Flow Control”ctx.retry(delay?)
Section titled “ctx.retry(delay?)”Instructs Requence to retry this service after an optional delay in milliseconds (minimum 100 ms). No code executes after this call.
createService('1.0.0', async (ctx) => { const db = await getDbConnection()
if (!db.isConnected) { ctx.retry(2000) // retry in 2 seconds }
return db.query('SELECT ...')})def handler(ctx): db = get_db_connection()
if not db.is_connected: ctx.retry(2000) # retry in 2 seconds
return db.query("SELECT ...")ctx.abort(reason?)
Section titled “ctx.abort(reason?)”Instructs Requence to abort this service immediately. If the on fail output is not connected, the entire task will fail.
createService('1.0.0', (ctx) => { if (!ctx.input.requiredField) { ctx.abort('Missing required field') }
return processData(ctx.input)})def handler(ctx): if not ctx.input.get("requiredField"): ctx.abort("Missing required field")
return process_data(ctx.input)ctx.toOutput(name, value)
Section titled “ctx.toOutput(name, value)”Routes execution to a specific named output on the service node. This is used when your service has multiple outputs defined in the service definition.
createService('1.0.0', (ctx) => { if (ctx.input.type === 'pdf') { return ctx.toOutput('pdf', { url: '...' }) }
return ctx.toOutput('other', { raw: ctx.input })})def handler(ctx): if ctx.input.get("type") == "pdf": return ctx.to_output("pdf", {"url": "..."})
return ctx.to_output("other", {"raw": ctx.input})ctx.defer(reason?)
Section titled “ctx.defer(reason?)”Marks the current message as deferred. The service acknowledges the message but signals that the work will be completed later via the act() API. This is useful for long-running processes or external callbacks.
createService('1.0.0', (ctx) => { const messageKey = ctx.defer('waiting for external process') // Store messageKey to use later with service.act()})def handler(ctx): message_key = ctx.defer("waiting for external process") # Store message_key to use later with service.act()ctx.terminated
Section titled “ctx.terminated”A Promise that resolves when the task is stopped — either because it was cancelled via the UI or API, or because another node triggered an exit. For continuous service generators, this is how you know the service should stop producing values.
ctx.terminated also exposes a .signal property — a native AbortSignal that fires at the same moment. Pass it directly to any abort-aware API (such as fetch, node:timers/promises setTimeout, or database clients) so that a pending await inside your generator exits immediately when the task is stopped, rather than waiting for the operation to complete naturally.
import { setTimeout } from 'node:timers/promises'
// Poll every 5 seconds, but exit immediately when the task is stoppedcreateService('1.0.0', async function* (ctx) { while (true) { await setTimeout(5_000, null, { signal: ctx.terminated.signal }) yield await pollForUpdates() }})You can also race ctx.terminated as a plain Promise when the API you are calling does not support AbortSignal:
const result = await Promise.race([ fetchData(), ctx.terminated, // resolves with the stop reason when the task is cancelled])Memory
Section titled “Memory”ctx.store.private(key, binding) / ctx.store.shared(key, binding)
Section titled “ctx.store.private(key, binding) / ctx.store.shared(key, binding)”Returns a handle to one value a node remembers, with the two axes said once each — the verb says who can read it, the argument says how long it lives.
'task' |
'taskTemplate' |
|
|---|---|---|
private(key, …) |
this node, this run | this node, across runs — a poll cursor |
shared(key, …) |
every node of this task, this run | every node of this template, across runs |
The handle has get(), set(value) and delete() on both verbs. The binding defaults to
'task' — deliberately the option that forgets, so anything outliving a run is spelled
out. The handle itself is local (nothing is requested until you use it), and set returns
only once the store has confirmed the write.
createService('1.0.0', async function* (ctx) { // shared, so a downstream node can move where the next run starts const cursor = ctx.store.shared<number>('lastMailUid', 'taskTemplate') const seen = ctx.store.private<number>('seen') // === private('seen', 'task')
for await (const mail of fetchSince((await cursor.get()) ?? 0)) { yield { subject: mail.subject } await cursor.set(mail.uid) // after the push, never before await seen.set(((await seen.get()) ?? 0) + 1) }})def handler(ctx): # No await in the Python SDK; the value type is a variable annotation cursor: StoreSlot[int] = ctx.store.shared("lastMailUid", "taskTemplate") seen: StoreSlot[int] = ctx.store.private("seen") # === private("seen", "task")
for mail in fetch_since(cursor.get() or 0): yield {"subject": mail.subject} cursor.set(mail["uid"]) seen.set((seen.get() or 0) + 1)A read answers the value or nothing — an unwritten slot and a slot written as null are
the same thing — so the cold start is always yours to handle. Values are JSON only, at most
64 KiB each, at most 64 names per slot group, with a 128-character key limit. delete() is
what frees a name; set(null) does not, because null is a value.
Two things to know before reaching for shared:
- There is no referee. Last write wins, and nothing serialises two nodes — the node’s Concurrency switch only makes a node wait for itself. Let one node own each shared name and have the others read it.
- The 64-name budget is shared too, so the refusal lands on whoever writes last and the names filling it need not be yours.
The store is the only enforcer. Neither SDK pre-checks, deliberately: the limit lives in one place, so the two languages cannot disagree about it, and every refusal comes back with the store’s own sentence. The cost is that an oversized value costs a round trip before you are told.
When a store call fails
Section titled “When a store call fails”A store call throws at the line that made it, and an uncaught throw fails the node the ordinary way — the node’s on fail output if it has one, the task if it does not. Nothing about it is special-cased, and nothing retries.
Every class below extends StoreError, so one catch can name the whole family:
import { StoreError, StoreUnavailableError } from '@requence/service'| Class | Means |
|---|---|
StoreUnavailableError |
unreachable, a 5xx, or an unparsable body |
StoreUnauthorizedError |
401 — the capability is missing, malformed, or not this store’s |
StoreTaskNotAllowedError |
403 — the capability verifies, but its run is over |
StoreRequestError |
400 — a malformed request, including an over-long key |
StoreAddressFullError |
409 — the node already holds 64 names |
StoreValueTooLargeError |
413 — over 64 KiB |
StoreValueNotStorableError |
422 — a file, stream or date reference at any depth |
Python spells them …Exception, one for one, all exported from requence.service:
StoreException is the base, with StoreUnavailableException, StoreRequestException,
StoreUnauthorizedException, StoreTaskNotAllowedException, StoreAddressFullException,
StoreValueTooLargeException and StoreValueNotStorableException beneath it.
A computed configuration field can read the store too
Section titled “A computed configuration field can read the store too”A computed configuration function gets the whole
store — private and shared, both bindings, get/set/delete. It is the natural place
to read a cursor: the value a node needs before it runs, that the last run produced.
// configuration function on the receiver node, field `startAfterUid`return (await context.store.shared<number>('lastMailUid', 'taskTemplate').get()) ?? 0# configuration function on the receiver node, field `start_after_uid`cursor: StoreSlot[int] = context.store.shared("lastMailUid", "taskTemplate")return (await cursor.get()) or 0All configuration fields of one node share one box: one 64-name budget and one namespace, both languages included. Two fields using the same name are the same slot, and the last write wins.
The box is cleared with the node — removing the node, or pointing it at another service version, takes it too, exactly as it takes the node’s own slots. A store failure in a configuration function fails the node the ordinary way: its on fail output if it has one, the task if it does not. Nothing degrades a failed read to “nothing stored”, so a cursor never silently restarts at zero.
Who cannot call the store
Section titled “Who cannot call the store”A durable callback and a
durable surface handler
have no ctx, and that is not an oversight to route around: the store capability is minted
per task and delivered on the dispatch, and neither of those calls is a dispatch. Both are
addressed by name, not by a running message, so there is no capability in scope to authorise
them.
Read and write the slot in the handler instead, and give the callback or handler what it
needs through its binding (.for({ … })) or its node configuration. A connection-bound
function is a closure inside a running handler, so it can use the ctx that handler already
has.
See The Store for what a template builder sees — including that changing a node’s service version cold-starts its slot, and that a test run from the editor shares the saved template’s slots.
Surfaces
Section titled “Surfaces”ctx.render(ui, props, options?)
Section titled “ctx.render(ui, props, options?)”Draws — or redraws — your service’s own UI component on this node in the task canvas. ui is a component you registered with createUI / create_ui; props is a plain object, and a function inside it arrives in the component as something the viewer can invoke.
const panelUI = createUI<{ title: string }>( new URL('./dist/panel.js', import.meta.url),)
createService('1.0.0', async (ctx) => { ctx.render(panelUI, { title: 'Processing' }) return await doTheWork(ctx.input)})panel_ui = create_ui(Path(__file__).parent / "dist" / "panel.js")
def handler(ctx): ctx.render(panel_ui, {"title": "Processing"}) return do_the_work(ctx.input)The call is fire-and-forget, like sending data: it is enrolled in the message’s ledger, so a failure fails the node when the message settles rather than at the call site. Each render is a patch — it updates the props it names and leaves the rest standing — and options names which surface on the node it targets (surfaceId / surface_id, default "default").
See Surfaces for the component contract, the two kinds of handler, and the rules that come with them.