PAIPAI

Suspend And Resume

Declare typed wait points for tools and workflows.

Suspend/resume is the generic mechanism behind pending actions.

A tool can reach a point where it cannot continue inside the backend runtime. It may need a person to answer a question, a client to provide data, an operator to upload a result, or a multi-step workflow to continue later.

In that case the tool suspends to a named wait point. The runtime persists a pending action. A client submits, cancels, or fails that action. The runtime then re-enters the tool from the persisted wait point.

Use submit() for ordinary typed answers, including rejection or "no" when the resume schema supports it. Use cancel() only when the wait is abandoned without a valid resume payload.

tool execute
  -> ctx.suspend(name, suspend data)
  -> pending action appears in ThreadState
  -> action.submit(resume data)
  -> tool re-enters with ctx.entry.resume

Define Typed Wait Points

Declare suspend points in the tool contract so TypeScript, clients, and persisted state know the suspend and resume shapes.

import { defineTool } from "@pai/core";
import { z } from "zod";

export const collectReportDetails = defineTool({
  id: "collectReportDetails",
  description: "Collect missing report details from the user",

  inputSchema: z.object({
    reportId: z.string(),
    knownSummary: z.string(),
  }),

  outputSchema: z.object({
    audience: z.string(),
    deadline: z.string().optional(),
  }),

  suspend: {
    collectDetails: {
      suspendSchema: z.object({
        prompt: z.string(),
        missingFields: z.array(z.enum(["audience", "deadline"])),
      }),
      resumeSchema: z.object({
        audience: z.string(),
        deadline: z.string().optional(),
      }),
    },
  },

  execute: async (ctx) => {
    if (ctx.entry.type === "initial") {
      return ctx.suspend("collectDetails", {
        prompt: "Who is this report for, and when is it due?",
        missingFields: ["audience", "deadline"],
      });
    }

    if (
      ctx.entry.type === "resume" &&
      ctx.entry.name === "collectDetails"
    ) {
      return {
        audience: ctx.entry.resume.audience,
        deadline: ctx.entry.resume.deadline,
      };
    }

    throw new Error("Unsupported tool entry");
  },
});

The suspendSchema validates the data passed to ctx.suspend(...); that data becomes action.input for clients to render. The resumeSchema validates the data passed to action.submit(...); that data becomes ctx.entry.resume when the tool re-enters. If a submit payload does not match resumeSchema, the submit is rejected and the tool remains waiting.

Suspend-point names are local inside the tool. Pending action names are globally qualified for the client. For a suspend point named collectDetails inside the tool collectReportDetails, the pending action name is collectReportDetails.collectDetails.

const action = thread.getState().pending[0];

if (action.name === "collectReportDetails.collectDetails") {
  await action.submit({
    audience: "Product team",
    deadline: "Friday",
  });
}

Multiple Wait Points

One tool can declare several wait points. Each entry pairs the suspend payload with the matching resume payload.

const draftReport = defineTool({
  id: "draftReport",
  inputSchema: input,
  outputSchema: output,

  suspend: {
    collectDetails: {
      suspendSchema: collectDetailsSuspendSchema,
      resumeSchema: collectDetailsResumeSchema,
    },
    approveDraft: {
      suspendSchema: approveDraftSuspendSchema,
      resumeSchema: approveDraftResumeSchema,
    },
    attachReferenceFile: {
      suspendSchema: referenceFileSuspendSchema,
      resumeSchema: referenceFileResumeSchema,
    },
  },

  execute: async (ctx) => {
    if (ctx.entry.type === "initial") {
      return ctx.suspend("collectDetails", {
        prompt: "What should this report include?",
      });
    }

    if (ctx.entry.type !== "resume") {
      throw new Error("Unsupported tool entry");
    }

    if (ctx.entry.name === "collectDetails") {
      return ctx.suspend("approveDraft", {
        summary: await draftFrom(ctx.entry.resume),
      });
    }

    if (ctx.entry.name === "approveDraft") {
      if (!ctx.entry.resume.approved) {
        return {
          status: "rejected",
          comment: ctx.entry.resume.comment,
        };
      }

      return ctx.suspend("attachReferenceFile", {
        reportId: ctx.input.reportId,
      });
    }

    if (ctx.entry.name === "attachReferenceFile") {
      return {
        fileId: ctx.entry.resume.fileId,
      };
    }
  },
});

Each pending action has its own name, input, and typed resume payload.

The action also carries origin metadata:

action.origin.toolName; // "draftReport"
action.origin.suspendName; // "approveDraft"
action.origin.messageId; // open assistant message that owns the tool part
action.origin.toolCallId;

Human Input Examples

Human input is not a separate primitive. Approval, confirmation, data collection, questionnaires, clarification questions, and multi-step forms are all suspend/resume wait points.

For approval, make approval and rejection part of the resume schema and answer with action.submit(...):

const requestApproval = defineTool({
  id: "requestApproval",
  description: "Request approval before sending a report",
  inputSchema: z.object({ summary: z.string() }),
  outputSchema: z.object({
    approved: z.boolean(),
    comment: z.string().optional(),
  }),
  suspend: {
    approval: {
      suspendSchema: z.object({ prompt: z.string() }),
      resumeSchema: z.object({
        approved: z.boolean(),
        comment: z.string().optional(),
      }),
    },
  },
  execute: async (ctx) => {
    if (ctx.entry.type === "initial") {
      return ctx.suspend("approval", {
        prompt: ctx.input.summary,
      });
    }

    if (ctx.entry.type === "resume" && ctx.entry.name === "approval") {
      return ctx.entry.resume;
    }

    throw new Error("Unsupported tool entry");
  },
});
const action = thread.getState().pending.find(
  (item) => item.name === "requestApproval.approval",
);

await action?.submit({
  approved: true,
  comment: "Send it.",
});

Use action.cancel() only when the wait is abandoned without a valid resume payload. Ordinary "reject", "no", or "try again" answers should be represented in the typed resume schema.

Entry

ctx.entry tells the tool why execute is running.

type ToolEntry =
  | {
      type: "initial";
    }
  | {
      type: "resume";
      name: string;
      resume: unknown;
      action: PendingActionRef;
    };

ctx.input is always the original model tool-call input. ctx.entry.resume is only present when the tool is re-entering from a submitted pending action.

Suspend History

The runtime persists suspend history for the current tool call. Use it when a tool has multiple wait points or needs to know how many times a step has been answered, cancelled, or failed.

const previousApproval = ctx.suspendHistory.find(
  (item) => item.name === "approveDraft" && item.status === "submitted",
);

if (previousApproval) {
  previousApproval.resume.approved;
}

History records include the suspend input, resolution status, and submitted resume payload when available.

type SuspendHistoryItem =
  | {
      name: string;
      suspendedAt: string;
      input: unknown;
      status: "submitted";
      resumedAt: string;
      resume: unknown;
    }
  | {
      name: string;
      suspendedAt: string;
      input: unknown;
      status: "cancelled";
      resolvedAt: string;
      cancel?: { reason?: string };
    }
  | {
      name: string;
      suspendedAt: string;
      input: unknown;
      status: "failed";
      resolvedAt: string;
      fail: { message: string };
    };

Public API

Suspend/resume is tool control flow. Consumers still answer pending actions.

await action.submit(data);
await action.cancel({ reason });
await action.fail(error);

action.cancel() records an abandoned wait as a model-visible output-error. It is not the normal way to answer "reject" or "no"; those should be part of the resume schema and submitted with action.submit(...).

If the resumed tool later returns final output that does not match the tool output schema, the runtime records the tool as output-error. Invalid backend-produced output is a tool implementation bug, not a client-correctable pending action.

That keeps the client API consistent across human input, backend tools without execute, client tools, and generic workflow waits.

On this page