Use submitMessages() when a caller needs to start a Think turn, return
quickly, retry safely, and inspect the result later.
saveMessages() waits for the turn to finish. submitMessages() durably accepts
the turn and returns a submission record before inference runs.
For a broader comparison with chat(), saveMessages(), and agent tools, see
Choosing a turn API.
Declarative scheduled prompt tasks use the same durable submission path under
the hood. Use getScheduledTasks() when the trigger is recurring and
code-declared; use submitMessages() directly when an external caller or
webhook creates one-off work.
Webhook handlers, RPC callers, and parent Workers often have strict timeout
limits. If they call saveMessages() and time out, they cannot tell whether the
turn was never accepted, is queued, is running, or already completed. Retrying
can duplicate a user message and start a second turn.
submitMessages() creates a durable acceptance boundary:
const submission = await this.submitMessages(
[
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Process webhook event 123" }]
}
],
{ idempotencyKey: "webhook-event-123" }
);The caller can return submission.submissionId immediately. Later, call
inspectSubmission(submissionId) or listSubmissions() to inspect status.
const submission = await this.submitMessages(messages, {
submissionId: "optional-stable-id",
idempotencyKey: "external-job-id",
metadata: { source: "webhook" }
});submitMessages() accepts serializable UIMessage[] values. It does not accept
the function form supported by saveMessages((messages) => ...), because
durable submissions persist work before execution and cannot store closures.
The array must contain at least one message.
| Status | Meaning |
|---|---|
pending |
Accepted and waiting for its turn |
running |
Claimed by the agent and executing |
completed |
The Think turn completed successfully |
aborted |
The submission was cancelled |
skipped |
Turn state was reset before the submission ran |
error |
Execution failed or recovery was unsafe |
Pass an idempotencyKey from your external system. Retrying with the same key
returns the existing submission with accepted: false instead of inserting
duplicate messages:
const first = await this.submitMessages(messages, {
idempotencyKey: payload.id
});
const retry = await this.submitMessages(messages, {
idempotencyKey: payload.id
});
// retry.submissionId === first.submissionId
// retry.accepted === falseIf you pass both submissionId and idempotencyKey, they must identify the same
submission. If they point at different existing rows, submitMessages() throws
instead of choosing one identity over the other.
const current = await this.inspectSubmission(submission.submissionId);
const active = await this.listSubmissions({
status: ["pending", "running"]
});
await this.cancelSubmission(submission.submissionId, "No longer needed");Once the turn persists its assistant message, the inspection carries that
message's id as messageId, so a caller that admitted the turn by submission
id can find the exact message it wrote, even if later turns have added messages
since.
Use cancelSubmission(submissionId) for durable cancellation. This works across
Worker and Durable Object RPC boundaries, unlike AbortSignal.
cancelSubmission() reports what it did, so a caller does not need a separate,
race-prone inspection to find out:
outcome |
Meaning |
|---|---|
cancelled |
The submission is now aborted. previousStatus is pending when it was removed before its turn started, or running when its turn had been claimed and was signalled to abort. messagesApplied says whether any of its messages reached the conversation. submission holds the new state. |
already_terminal |
The submission had already finished. submission holds its final state, which is unchanged. |
not_found |
No submission has this id. |
A running cancellation stops the turn, but work a tool had already started,
such as an external request, may still finish.
waitForSubmission(submissionId) resolves once the submission reaches
completed, aborted, skipped, or error, after onSubmissionStatus has run
for it. It returns immediately for a submission that has already finished and
returns null for an unknown id. An error status resolves rather than
rejects, so read status and error from the result.
const { submissionId } = await agent.submitMessages([message], {
idempotencyKey: `${workflowName}:${instanceId}:${stepName}`
});
const settled = await agent.waitForSubmission(submissionId, {
timeoutMs: 60_000
});
if (!settled) {
throw new Error("Submission not found");
}
if (settled.status === "pending" || settled.status === "running") {
throw new Error("Still running; retry this step");
}With timeoutMs, the call returns the submission as it is when the time runs
out, still pending or running, or null if it was deleted. The wait is held in the agent's memory, so it
rejects if the agent restarts. The submission itself is durable, so call
waitForSubmission() again to keep waiting.
Completed submission records are retained until you delete them:
await this.deleteSubmissions({
status: ["completed", "error", "aborted"],
completedBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
});Think stores accepted submissions in a submission ledger first. It appends the
submitted messages to the conversation Session only when the submission starts
executing. This preserves FIFO turn semantics: later accepted submissions are
not visible to the model until their own turn starts.
If you cancel a submission before its messages have been applied, including one that has been claimed but is still waiting for its turn, those messages are not persisted to the conversation.
If the chat is cleared or turn state is reset before a pending submission runs,
that submission is marked skipped.
Use saveMessages() when the caller can wait for the full turn:
const result = await this.saveMessages(messages);
// result.status is finalUse submitMessages() when timeout ambiguity would make retries unsafe:
const result = await this.submitMessages(messages, { idempotencyKey });
// result.status is the accepted submission statewaitUntilStable() is still useful when the caller needs to avoid accepting new
work while the current chat UI is mid-turn. It is not required for durable
admission: accepted submissions are serialized by Think and do not append their
messages to the session until their own turn starts.
Use submitMessages() for one durable Think chat turn.
Use Workflows for multi-step orchestration: retries per step, long waits, external events, human approvals, or pipelines that may trigger Think as one part of a larger process.
Use startFiber() when the durable unit is agent-owned side-effect work around
a turn, such as accepting a webhook once, restoring a serialized chat thread,
and posting a visible reply. submitMessages() owns Think's message/session
admission; startFiber() owns the surrounding application job.
Use the two APIs together when the external job is larger than the Think turn itself:
await this.startFiber(
"reply-to-webhook",
async (ctx) => {
ctx.stash({ webhookId, threadId });
const submission = await this.submitMessages(messages, {
idempotencyKey: `turn:${webhookId}`,
metadata: { threadId }
});
await postVisibleReply(threadId, submission.submissionId);
},
{
idempotencyKey: `webhook:${webhookId}`,
waitForCompletion: true
}
);The outer managed fiber answers: "Did this webhook job get accepted, recovered,
cancelled, or resolved?" The inner submission answers: "Did this Think turn get
admitted to the session and complete?" Keep those boundaries separate. Do not
replace Think's internal chatRecovery fibers with managed fibers unless the
work is an application-owned job that callers need to inspect or dedupe.
Workflows can compose with this API:
const submission = await this.agent.submitMessages(messages, {
idempotencyKey: event.payload.jobId
});See Think Durable Submissions for a full dashboard that shows immediate ACKs, idempotent retry, queue status, and cancellation.