n8n-io/n8n · error · Error

Tool "${this.name}" has .suspend() but missing .resume()

Error message

Tool "${this.name}" has .suspend() but missing .resume()

What it means

Tool.build() enforces that suspend and resume schemas are declared together. .suspend(schema) defines the payload the tool yields when pausing for external input; .resume(schema) defines the payload accepted when resuming. A suspend without a resume leaves the tool unable to accept resume input, so the agent loop could never continue it — rejected at build to surface the mismatch early.

Source

Thrown at packages/@n8n/agents/src/sdk/tool.ts:374

	build(): BuiltTool {
		if (!this.name) {
			throw new Error('Tool name is required');
		}
		if (!this.desc) {
			throw new Error(`Tool "${this.name}" requires a description`);
		}
		if (!this.inputSchema) {
			throw new Error(`Tool "${this.name}" requires an input schema`);
		}
		if (!this.handlerFn) {
			throw new Error(`Tool "${this.name}" requires a handler`);
		}

		const hasSuspend = this.suspendSchemaValue !== undefined;
		const hasResume = this.resumeSchemaValue !== undefined;

		if (hasSuspend && !hasResume) {
			throw new Error(`Tool "${this.name}" has .suspend() but missing .resume()`);
		}
		if (hasResume && !hasSuspend) {
			throw new Error(`Tool "${this.name}" has .resume() but missing .suspend()`);
		}

		const hasApproval =
			(this.requireApprovalValue ?? false) || this.needsApprovalFnValue !== undefined;
		if (hasApproval && (hasSuspend || hasResume)) {
			throw new Error(
				`Tool "${this.name}" cannot use both approval (.requireApproval/.needsApprovalFn) and suspend/resume (.suspend/.resume)`,
			);
		}

		const built: BuiltTool = {
			name: this.name,
			description: this.desc,
			systemInstruction: this.systemInstructionText,
			suspendSchema: this.suspendSchemaValue,

View on GitHub (pinned to 5ac6606e81)

Solutions

  1. Add a matching .resume(z.object({...})) call defining the payload your resume path will send back.
  2. If you only need approval-style interruption (not a custom resume payload), remove .suspend() and use .requireApproval(true) instead — the approval schemas are wired automatically.
  3. Review the agent's resume entry point to ensure the resume schema matches what it will actually deliver.

Example fix

// before
const t = new Tool('ask_user')
  .description('Ask the user a question')
  .input(z.object({ question: z.string() }))
  .suspend(z.object({ question: z.string() }))
  .handler(...); // missing .resume()

// after
const t = new Tool('ask_user')
  .description('Ask the user a question')
  .input(z.object({ question: z.string() }))
  .suspend(z.object({ question: z.string() }))
  .resume(z.object({ answer: z.string() }))
  .handler(...);
Defensive patterns

Strategy: validation

Validate before calling

function interruptibleTool(name: string, suspendSchema: unknown, resumeSchema: unknown) {
  if ((suspendSchema && !resumeSchema) || (resumeSchema && !suspendSchema)) {
    throw new Error('suspend and resume schemas must be provided together');
  }
  const t = new Tool(name);
  if (suspendSchema) t.suspend(suspendSchema as any);
  if (resumeSchema) t.resume(resumeSchema as any);
  return t;
}

Type guard

function isPairedSuspendResume(suspend?: unknown, resume?: unknown): boolean {
  return (suspend !== undefined) === (resume !== undefined);
}

Prevention

When it happens

Trigger: Calling .suspend(z.object({...})) without a matching .resume(...), then building. The check fires after name/description/input/handler validation and before the approval-combination check.

Common situations: Adding a suspend step to an existing tool and forgetting the resume schema; copy-pasting half of a human-in-the-loop pattern; refactoring that drops one of the pair.

Related errors


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/b2b47f7474e32d6d. Report an issue: GitHub.