Skip to content

Callbacks

Callbacks let a downstream node send data back to an upstream service. This enables bidirectional communication within a task — a service can emit a value together with a function, and a later node decides whether to call it.

A callback comes in two kinds, and the service author picks which one when they write it:

Survives a redeploy, restart or scale event?
Durable Yes. Any instance of the service can run it — including one started after the emitting instance is gone.
Connection-bound No. It lives only in the process that emitted it, and only until that message finishes.

This is a template design decision, not just a service detail:

  • A callback that is used immediately — a logic node right after the service picks one of the options it just offered — can be either kind.
  • A callback the graph may call later — after a wait, after a human decides, after a node that only runs tomorrow — must be the durable kind. A connection-bound one is dead by then, and nothing tells the graph: calling it looks exactly like calling a live one, and simply nothing happens on the other side.

If a template hands a callback to a node that may not run for a while, check with whoever owns the service that it is the durable kind.

A durable callback that nobody can run — the service was scaled to zero, the version was deleted, or a colleague stopped their local dev process — is reported on the node in the task run, saying which of those it was. It waits about a minute for an instance to pick it up, so an ordinary redeploy is invisible.

Callbacks are useful when a service needs input from a downstream node before it can proceed, or when it wants to offer the graph an action on the value it just emitted:

  • A service generates options → a logic node selects one → the service continues with the selection
  • A service requests approval → a downstream step decides → the service acts on the decision
  • A service emits a mail → a logic node decides it is spam → it calls delete() on that mail

The first two wait for an answer, so they are connection-bound. The third does not, so it should be durable.

1. The Service Creates and Yields a Callback

Section titled “1. The Service Creates and Yields a Callback”

A connection-bound callback, which the service then waits on:

import { createService, createCallback } from '@requence/service'
createService('1.0.0', async function* (ctx) {
const callback = createCallback<number>()
// Yield the callback as part of the output
yield { chooseAmount: callback }
// Wait for the downstream node to respond
const amount = await callback.response()
// Continue with the received value
yield ctx.toOutput('result', { total: amount * 10 })
})

A durable callback, declared once and given this emission’s data:

import { createDurableCallback, createService } from '@requence/service'
// Declared once, outside the handler — that is what makes it durable.
const deleteMail = createDurableCallback<null, { uid: number }, '1.0.0'>(
'deleteMail',
async (_arg, { uid, configuration }) => {
await deleteFromMailbox(configuration, uid)
},
)
createService('1.0.0', async function* () {
for await (const mail of mails) {
yield { subject: mail.subject, delete: deleteMail.for({ uid: mail.uid }) }
}
})

The callback arrives as a callable function on the downstream node’s input, and it looks the same whichever kind it is. Calling it sends the value back:

// Logic node script
await context.input.chooseAmount(42)

In a logic node the call is asynchronous: it crosses the sandbox boundary, and what you await is the dispatch, not an answer — a callback never returns one. The await is optional, since the node cannot finish while an invocation is still in flight, and it buys ordering rather than error handling: a failed dispatch fails the node, it does not throw at your line. In a service, a reconstructed callback is a plain call with nothing to await.

This holds in both logic-node languages, and it holds for a callback you minted yourself with createCallback / create_callback — calling that one resolves locally, so its await returns at once rather than waiting for anything.

A callback that carries no value is called with nothing:

if (context.input.subject.startsWith('Spam')) {
await context.input.delete()
}

For a connection-bound callback, callback.response() resolves with the value (42 in this example). The service can then continue its execution and yield further results.

A durable callback has no waiting handler to resolve. Requence delivers the invocation to the service version, which runs the declared function — this instance, or whichever one is free.

A complete task template using callbacks might look like this:

  1. EntryService (generator, yields a callback) → Logic (invokes callback) → Exit
  2. The service also has a named output result that connects directly to the exit
// Service handler
createService('1.0.0', async function* (ctx) {
const callback = createCallback<number>()
yield { multiply: callback }
const factor = await callback.response()
yield ctx.toOutput('done', { result: factor * 100 })
})
// Logic node script
context.input.multiply(10)

The task completes with { result: 1000 }.

A service can create and yield multiple callbacks, either sequentially or in the same yield, and they need not be the same kind:

createService('1.0.0', async function* (ctx) {
const approve = createCallback<boolean>()
const setAmount = createCallback<number>()
yield { approve, setAmount }
const [approved, amount] = await Promise.all([
approve.response(),
setAmount.response(),
])
if (approved) {
yield ctx.toOutput('confirmed', { amount })
}
})

createCallback is also available as a global inside logic nodes — see Logic Nodes. A logic node’s own callback is always connection-bound; only a service can declare a durable one.