For the complete documentation index, see llms.txt. This page is also available as Markdown.

Interruption

The key breakthrough is that interruption isn't a bug, it's a feature.

Neuron's interruption pattern lets a Workflow pause execution and wait for external input — a human decision, or an event from another system — and resume later, even in a different process, hours or days after.

In this major version the mental model rests on one rule:

A pause sends data out of a node. A resume brings data back in.

  • Outbound — InterruptRequest: the description of the pause. A node constructs it to tell the outside world what it is waiting for (actions to approve, an event name, content to review). It is immutable, and it is never handed back into the workflow.

  • Inbound — the payload array: the answer that satisfies the pause. A plain, serialization-safe array delivered via resume(payload: [...]). The interrupted node receives it as the return value of the suspend call.

How it works

An AI workflow often needs to stop mid-flight, typically to ask a human for approval before acting, without keeping a PHP process alive while the human decides. To pause, a node calls interrupt(): the workflow stops traversal, persists its progress, and returns normally to the caller with the state marked as interrupted.

namespace App\Neuron;

use NeuronAI\Workflow\Events\Event;
use NeuronAI\Workflow\Interrupt\Action;
use NeuronAI\Workflow\Interrupt\ApprovalRequest;
use NeuronAI\Workflow\Node;
use NeuronAI\Workflow\WorkflowState;

class ApprovalNode extends Node
{
    public function __invoke(PurchaseEvent $event, WorkflowState $state): Event
    {
        // Suspend the workflow, carrying the request OUTBOUND.
        $payload = $this->interrupt(
            new ApprovalRequest(
                message: 'Do you approve this purchase?',
                actions: [
                    new Action(
                        id: 'purchase_1',
                        name: 'Purchase',
                        description: "Buy {$event->item} for {$event->price}$",
                    ),
                ],
            )
        );

        // Code below this line runs ONLY on resume.
        // $payload is the INBOUND answer delivered by resume() — your node interprets it.
        if (($payload['purchase_1'] ?? null) === 'approve') {
            return new PurchaseApprovedEvent();
        }

        return new PurchaseRejectedEvent();
    }
}

The lifecycle:

  1. Request — the node builds an InterruptRequest describing the pause and calls interrupt().

  2. Suspend — the executor stops traversal, persists the workflow's steps, and marks the returned state as interrupted. The request travels outbound on the state for your application to render.

  3. Decision — your application presents the request to a human and collects the answer.

  4. Resume — you call resume($payload) on a workflow rebuilt with the same runId. Traversal replays: completed nodes are skipped, and the interrupted node re-runs with the payload injected — interrupt() returns it instead of suspending again.

The Persistence layer

Suspend & resume works by replay: every node executes as a durable step, and completed steps are persisted so a resumed run can skip straight to the interrupted node. That requires a persistence backend. Without one, there is nothing to resume from.

The default InMemoryPersistence supports suspend & resume within the same process (useful in tests); use FilePersistence, DatabasePersistence, or EloquentPersistence when the resume happens in a later request or a background worker.

The request is fire-and-forget. The InterruptRequest itself is not persisted, only an "interrupted" flag is stored per step. On resume the node re-executes and rebuilds the request deterministically (replay-by-rerun).

Two consequences:

  • You may safely put live object instances in a custom request, nothing is ever serialized.

  • If your application needs to show "what is this run waiting for?" later, store the request yourself at suspend time (see below), the framework hands it to you through the returned state.

Catching the interruption

After a suspend, your application has to detect that the workflow paused, show the request to a human, and keep a handle to come back later. Since run() returns normally, this is a plain check on the returned state: isInterrupted() tells you the workflow paused, getInterruptRequest() gives you the outbound request to render, and the runId is the resume token.

Every InterruptRequest is JsonSerializable, so json_encode($request) gives your frontend everything it needs to render the pause.

Resuming

Once you have the human's answer, rebuild the workflow with the same runId , and deliver the inbound payload. You never rebuild or pass back the request — the payload alone is the answer. resume() takes no step identifier: the framework finds the interrupted step by replaying.

If the workflow suspends again downstream (multiple approval points), the returned state is interrupted again and the cycle repeats with the same runId.

Conditional interruption

Use interruptIf() to suspend only when a condition holds. The condition can be a boolean or a callback; when it doesn't hold, the method returns null and execution continues.

Custom interruption request

The built-in ApprovalRequest models approve/reject decisions on a list of actions. When your pause needs a different shape — say, a human editing generated content before it's saved — subclass an existing request type and add the outbound context your UI needs. Extend WaitForEventRequest (a human answer is an external event delivered to the workflow); you specialize the payload, not the pause category.

Notice what is not there: no fromArray(), no setters, no feedback fields. The request never round-trips, so it needs no deserialization and no mutation, it only describes the pause. Because it is never serialized by the framework, it could just as well carry a full Message object or any other live instance.

Use it in a node. The answer comes back as the payload:

And on the application side:

Waiting for an external event

Sometimes the answer doesn't come from a human but from another system, a payment webhook, a document upload, a callback from a third-party API. For this case awaitEvent() suspends the workflow until an event with the given name is delivered. It's sugar over interrupt() with a built-in WaitForEventRequest, so no custom class is needed, and the event body arrives as the payload.

Delivering the event is just a resume from wherever the event lands in your application — typically a webhook controller:

The event name on the request is what your application uses to route the right event to the right run. The framework doesn't dispatch events itself; your code decides which suspended runId an incoming event belongs to.

Consuming the feedback

The answer to the interruption is the payload you pass to the run() method, and the primary way to consume it is the return value of the suspend verb, exactly where the pause happened:

If you need to branch before reaching the interrupt call — for example to skip pre-interrupt logic entirely on resume — the node exposes the resume context directly:

In most nodes you won't need this: wrap the pre-interrupt work in memoize() and let the node re-run. The memoized work is recalled, not repeated, and the linear interrupt()-returns-the-answer style stays readable.

Durable steps and memoize()

When a workflow resumes, the interrupted node re-executes from the top — any statement before the interrupt call would run again, re-billing an LLM call or re-sending an email. Two layers of durability protect you.

Between nodes, you get durability for free. Every node executes as a durable step: completed steps are persisted and skipped on replay. Nodes before the interrupted one never re-run.

Inside a node, use memoize(). It executes a closure and persists its return value mid-node, before the node returns. When the node re-executes — on resume, or after a crash — the recorded value is returned without running the closure again.

memoize() takes:

  • a name, unique within the node (the framework scopes it to the specific node execution automatically);

  • a Closure wrapping the work whose result must survive a re-run.

checkpoint() from the previous major version is deprecated and now delegates to memoize(). Unlike the old in-memory checkpoint, memoize() is durable: the value is persisted, so it also protects against crashes — not just interruptions.

Last updated