Skip to content

Latest commit

 

History

History
251 lines (192 loc) · 9.94 KB

programmatic-submissions.md

File metadata and controls

251 lines (192 loc) · 9.94 KB

Programmatic Submissions

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.

Why this exists

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.

API

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.

Statuses

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

Idempotent retries

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 === false

If 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.

Inspect, list, and cancel

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.

Wait for a submission

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)
});

Session behavior

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.

Compared with saveMessages

Use saveMessages() when the caller can wait for the full turn:

const result = await this.saveMessages(messages);
// result.status is final

Use submitMessages() when timeout ambiguity would make retries unsafe:

const result = await this.submitMessages(messages, { idempotencyKey });
// result.status is the accepted submission state

waitUntilStable() 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.

Compared with Workflows

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.

Layering with startFiber()

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
});

Example

See Think Durable Submissions for a full dashboard that shows immediate ACKs, idempotent retry, queue status, and cancellation.