Resume Interrupts Programmatically

Hi,

I’m using LangGraph (JS/TS) with interrupt() for HITL-style user input. The graph pauses correctly and emits an interrupt, and I understand that the run is over at that point and must be resumed externally using Command({ resume }).

What I’m trying to implement is automatic resumption after a timeout if the user does not respond.

Conceptually, what I want is:

  • Node calls interrupt() to request user input

  • Graph run ends and state is checkpointed

  • If the user responds in time → resume with user input

  • If no response after X seconds → resume programmatically with a default value (e.g. "timeout") – Resume the graph execution to the next node.

I understand that:

  • This cannot be implemented inside the graph or node itself (or maybe it can and I didn’t think of it)

  • A busy-wait loop is not appropriate

  • Resumption must be done programmatically by the application layer

My question is what is the intended / recommended pattern for this in LangGraph?

Specifically:

  1. Is the expected approach to schedule an external timer/job (e.g. setTimeout, queue, cron) that calls graph.invoke(new Command({ resume }))? This also means I need a setInterval to check for if timeouts exist and then for n timeouts, create an interval every x seconds that validates if they should be discarded or return graph.invoke(newCommand({resume})).

  2. Is there a canonical way to associate a timeout with a specific interrupt (e.g. via interrupt IDs)?

  3. How should race conditions be handled (user reply vs timeout firing)?

  4. Is there any planned first-class support for time-based resumption, or is this intentionally left to the application layer?

I’m aiming for a scalable, multi-user setup (each thread_id independent), not a blocking loop per user.

Any guidance or examples from the maintainers would be greatly appreciated.

Thanks!

Hey! You have the right idea – LangGraph leaves scheduling/timing concerns to the application layer. Part of the architecture solve that LangGraph provides is that graph states can be stored and re-established from any process. If we became opinionated about scheduling, that means we have additional opinions on how your infrastructure/workers should look like and we (probably) don’t have enough value that we can provide there meaningfully.

Is the expected approach to schedule an external timer/job (e.g. setTimeout, queue, cron) that calls graph.invoke(new Command({ resume }))?

Yes! This is a pattern we’ve seen before that works well

This also means I need a setInterval to check for if timeouts exist and then for n timeouts

Any reason you couldn’t have a setTimeout that invokes with a new command?

Is there a canonical way to associate a timeout with a specific interrupt (e.g. via interrupt IDs)?

No, think that would just be solved by the nature of the callback you give to setTimeout

How should race conditions be handled (user reply vs timeout firing)?

This is a user-land concern also I believe. You can invoke a thread concurrently, but we take the attitude of “last-write wins” when evaluating what the final thread state should be

Is there any planned first-class support for time-based resumption, or is this intentionally left to the application layer?

No immediate plans

1 Like

Thank you for the assistance :slight_smile:

Edit:

Figured I’d post a code solution if anyone encounters a similiar problem. For a graph without loops, invoking the graph, awaiting response and if it returned an __interrupt__ field then using a simple timeout with a resume command callback will work to programmatically resume the interrupt.
However, for a graph with loops (start_node→…→interrupt_hitl_node→…→start_node→…) it’s a bit more complicated to handle interrupts iteratively (and more specifically triggering a timeout for them).
The solution I came up with involved running a while loop, it allowed to keep the graph running and whenever interrupts were raised handle them in switch cases.
Maybe there are better ways to handle interrupts in a graph with loops, if someone has feedback it’ll be appreciated.

Main Code:

type InterruptValue = { reason: string; [k: string]: unknown };
async function runGraph(state: ArtistGraphState = initialState, config: Config) {
  // Keep invoking until the graph finishes (i.e., no interrupt is returned).
  let run = await graph.invoke(state, config);

  while (isInterrupted<InterruptValue>(run)) {
    const interrupt = run[INTERRUPT][0];

    // handle interrupts here
    switch (interrupt.value?.reason) {
      case 'getUserInput': {
        const userInput: 'timeout' | string = await handleGetUserInputInterrupt();

        // If userInput==timeout then invoke with a timeout command and handle it in node..
        run = await graph.invoke(new Command({ resume: userInput }), config);
        break;
      }

      // Add more cases here for other interrupts
      case 'placeholderInterruptName': {
        // perform logic
        // ...
        // ...
        run = await graph.invoke(new Command({ resume: 'ValueToResumeWith' }), config);
        break;
      }

      default:
        return run;
    }
  }

  return run;
}

const run = await runGraph(initialState, config);

Specific example code & utils:

async function handleGetUserInputInterrupt(): Promise<'timeout' | string> {

  const controller = new AbortController();

  let timeout: ReturnType<typeof setTimeout> | undefined;

  const timeoutPromise = new Promise<'timeout'>((resolve) => {

    timeout = setTimeout(() => {

      controller.abort();

      resolve('timeout');

    }, 25_000);

  });

  
  try {

    // gets user input using readline interface, returns Promise<string>

    const userInput = getUserInput('Please enter your input:', controller);

    

    const result = await Promise.race<'timeout' | string>([timeoutPromise, userInput]);

    

    return result;

  } catch (error) {

    if (error instanceof Error && error.message === 'Abort') return 'timeout';

    

    throw error;

  } finally {

    if (timeout) clearTimeout(timeout);

  }

}

// Utility function for getting user input

export function getUserInput(prompt: string = '', controller: AbortController): Promise<string> {

  const rl = readline.createInterface({

    input: process.stdin,

    output: process.stdout,

  });




  return new Promise((resolve, reject) => {

    let settled = false;




    function cleanup() {

      controller.signal.removeEventListener('abort', onAbort);

      readline.clearLine(process.stdout, 0);

      readline.cursorTo(process.stdout, 0);

      rl.close();

    }




    function onAbort() {

      if (settled) return;

      settled = true;

      cleanup();

      reject(new Error('Abort'));

    }




    controller.signal.addEventListener('abort', onAbort, { once: true });




    rl.question(prompt, (answer) => {

      if (settled) return;

      settled = true;

      cleanup();

      resolve(answer.trim());

    });

  });

}
1 Like