Finish a Response, Continue the Workflow
Updated 3 days ago • October 4, 2026
Use completeResponse() when the client has everything it needs but your workflow
still has internal work to do—for example, evaluating a conversation after answering
it. This is an advanced response-lifecycle feature. Ordinary workflows can continue
to finish naturally; existing useInterrupt() code does not change.
Completing a response does not complete execution. The response closes once; subsequent nodes, handoffs, model calls, execution limits and telemetry continue. Kortyx runs this work in your application's process. It does not create a worker, make a job durable, or require Studio.
Try it in the example
Start examples/kortyx-nextjs-chat-api-route and open /background. No model key
or Studio installation is needed for this example.
- Click Start example. The answer appears and the connection closes.
- Internal analysis continues briefly. Click Refresh pending reviews.
- The application's separate review section shows a pending question.
- Click Save or Skip. The backend resumes the run and the chat stays closed.
The example uses a server-set, HTTP-only cookie to isolate demo sessions. A real application must derive its scope from authenticated users and authorization rules. If telemetry is configured, inspect the same run and interrupt in Studio. Studio shows Response: Completed independently of execution status and flags an interrupt requested after response completion. Studio has no approval controls.
Put completion after the last response-producing node
Connect respondNode to finishResponseNode in your graph. The first node's
returned state and output are already committed when completion runs.
You can also supply final content directly:
message emits a normal message chunk. data emits a final generic structured-data
chunk; it must be JSON-compatible. It does not implicitly update workflow data.
The node's return still updates internal data and routing, but returned UI content
after completion is not sent to the client. Empty completeResponse() emits no
extra content and never automatically exposes accumulated workflow state.
The call resolves when server-side finalization succeeds. It is not an acknowledgement that the client received the bytes. Repeated calls after successful completion are harmless. If the node fails later, execution can fail even though the response has already completed; inspect execution telemetry for that outcome.
Parallel branches and child workflows
Completion applies to the whole response, not just the calling node. It does not
wait for sibling branches. If both branches contribute to the answer, join them
first and call completeResponse() in the following node. A sibling still running
after completion can continue internally, but its later output is not delivered.
This feature does not add support for parallel child calls.
Only root-workflow nodes can close the response. A workflow reached through
transitionTo is a root handoff; a workflow invoked with useWorkflow() is a child
and cannot close its caller's response. Await response-producing work before
completion; do not leave a structured response partially emitted.
Keep the execution attempt alive
The application hosting integration owns the remaining process lifetime. The
onExecution option receives a promise that settles after the execution attempt
finishes, fails, cancels, or suspends. The promise resolving does not mean the run
succeeded. It lets a host retain the request's background work without keeping the
response stream open.
For a Next.js route:
For a custom transport, pass onExecution to agent.streamChat. Connect it to
your host's lifetime mechanism. Host time limits still apply. A process terminating
during active work is not automatically recovered; durable scheduling is a separate
application responsibility. Suspensions can be resumed from configured durable
execution persistence, such as the existing Redis adapter.
Before completion, the chat request's abortSignal and stream cancellation stop
cooperative execution. After completion, client disconnect no longer cancels it.
Pass a separate executionSignal to agent.streamChat when server-controlled
cancellation must remain effective throughout. Models, tools and useAbortSignal()
receive the live execution signal. Completion never removes execution limits.
Save the visible chat response on the server
An application can persist a turn without reading or teeing the SSE response. For the full callback, retry, checkpoint, and hydration contract, see Server-Owned Chat Transcripts.
authenticateRequest, appDb, and reportAppPersistenceFailure are
application functions. Authenticate the session before the generated handler;
client-provided session IDs and context are not proof of ownership. Once either
chat lifecycle hook is configured, requests need a nonempty sessionId and
clientTurnId. The default @kortyx/react route transport sends the user message
ID as clientTurnId for prompts and interrupt responses. A retry of the same
attempt must reuse that ID. Use an idempotent upsert scoped to the authenticated
conversation; the key prevents duplicate transcript rows, not duplicate model
execution.
The accepted request's userMessage.metadata can contain an interrupt resume
token. Persist only the fields the application needs. A finalized interrupt
piece also contains a resume token so a reloaded UI can answer it; protect
transcript reads with the same ownership check as the checkpoint route.
onTurnAccepted is awaited before execution. If it fails, the request returns a
typed persistence error and the run does not start. onResponseFinalized receives
one server-built assistant message with content and ordered contentPieces:
text, reduced structured data, pending interrupt, and error pieces. A failed or
interrupted response can contain partial pieces. It runs after the response's
checkpoint decision and is separate from any later background execution. A
failure in this callback leaves the runtime outcome intact and reaches
onLifecycleError as ChatLifecycleHookError.
disconnect: "continue" keeps execution running after the SSE reader closes.
The host must retain onExecution; its promise includes finalization. This
setting also means a browser AbortController or useChat.abort() closes the
client stream without cancelling server work. Implement a separate authorized
server cancellation path with a custom route and agent.streamChat's
executionSignal if the product needs a Stop control. The default
disconnect: "cancel" preserves the existing cancellation behavior.
Callbacks are attempted once per run attempt while the process is alive. They
are not a durable delivery queue: a process crash between checkpoint commit and
the callback can leave a pending app row. Reconcile stale rows in the app or use
an app-owned durable outbox if crash recovery is required. stream: false still
returns { chunks, text, structured } to the HTTP caller; the callback receives
the same parsed message as in SSE mode. ChatStorage.load() can hydrate the
server-owned transcript, while the client should use includeHistory: false
when the server supplies model history.
Human input after the response closes
Keep the normal hook:
Kortyx persists the suspension. The question cannot travel over the closed chat connection, so your application displays it elsewhere. Nodes do not save resume IDs, register callbacks, or implement approval delivery.
There are two ways to discover it:
- Your backend: call
agent.listInterrupts()using an authorized scope. - Studio: inspect pending interrupts and use their public interrupt IDs in your application's approval tooling. Studio is optional and read-only; telemetry is not authoritative execution state and may arrive late.
The returned array contains public IDs, run/session/workflow/node identifiers,
question/options, creation/expiry timestamps, and afterResponseCompleted. It
contains no private resume handles, execution snapshots, or internal hook metadata.
Only fully prepared, unexpired suspensions are returned.
Resolve an answer through an application-owned endpoint:
getInterrupt includes a private resume handle for server-side use. Never
return that object wholesale to the browser. An ID does not authorize an answer.
Authorize the actor and scope on the backend; agent.resume validates the actual
pending request and claims it once. Another actor may resolve it between lookup
and resume, so handle rejected/stale responses.
The scope must contain a nonempty sessionId, runId, or context filter. Supplied
filters are combined with AND; context values use exact equality. These filters
help enforce application scope but do not authenticate callers. Both built-in
memory and Redis adapters implement discovery. Custom adapters can add the optional
pendingRequests.list capability; unsupported discovery throws explicitly.
Discovery currently enumerates the configured pending store: use a dedicated
application namespace and avoid polling it on every render.
A resumed background run keeps the response closed. It can suspend again, so handle
all agent.resume outcomes and rediscover any later question. Limit exhaustion also
creates a discoverable Continue request; only your authorized application should
approve additional work. Without an approval interface, the run remains suspended
until answered or expired. Configure persistence TTL for your expected review time;
the default execution TTL is 15 minutes.
Checkpoints and entry points
The foreground snapshot contains state available at the call. It cannot include future return values or pretend the calling node has finished. Put completion in the next node when the preceding node's return must be in that snapshot. Explicit final response data is independent of the workflow's final output schema; normal execution output validation still runs when the workflow actually finishes.
Boundaries
Kortyx owns execution, suspension, resume, and configurable execution persistence. Applications own chat archives, business records, approval interfaces, authorization, and background scheduling. Studio observes. This feature introduces no memory system, message database, PostgreSQL requirement, or Studio dependency.