> For the complete documentation index, see [llms.txt](https://docs.neuron-ai.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.neuron-ai.dev/neuron-v4/agent/channel.md).

# Channel

When you run a Workflow, it can produce output while it is still running: text chunks coming from the LLM, tool call notifications, or any custom object a node decides to yield. A Channel is the component that decides where this output goes.

### The problem it solves

There are two ways to run a Workflow. You can iterate its events, or you can just ask for the final result:

```php
// You consume the stream yourself
foreach ($workflow->events() as $event) {
    // handle each streamed item
}

// You only want the final state
$state = $workflow->run();
```

The first style works well when your code holds the stream from start to finish, like a controller that keeps the HTTP connection open and prints every chunk to the browser.

The problem appears when nobody is holding the stream. Think about a workflow that runs in a background job. The job calls `run()`, the workflow produces chunks, but there is no browser attached to that process. Without a Channel, all that output is simply thrown away. The user watching the chat in another tab sees nothing until the run is over.

In other words, delivery of the output was tied to who executes the workflow. The Channel breaks this link. You attach a Channel to the workflow, and the workflow pushes every streamed item into it, no matter who started the run or from where. The Channel then forwards the output to your transport: a websocket, a Redis queue, an SSE response, a log, anything you want.

### The contract

A Channel is any class that implements `ChannelInterface`. It has four methods. One receives the streamed items, and three tell you how the run ended:

```php
namespace NeuronAI\Workflow\Channel;

interface ChannelInterface
{
    // A streamed item produced while the workflow is running.
    public function send(object $item): void;

    // The run paused, waiting for external input (human approval, an event, a timer).
    public function suspended(InterruptRequest $request, string $runId): void;

    // The run finished with success.
    public function completed(WorkflowState $state, string $runId): void;

    // The run stopped because of an exception.
    public function failed(Throwable $exception, string $runId): void;
}
```

Every run segment ends with exactly one of the three terminal calls: `suspended()`, `completed()`, or `failed()`. This is important for user interfaces. If a run fails and you have no error signal, the user is left with a spinner that never stops. With `failed()` your frontend always receives a clear end signal and can recover, for example by reloading the chat history.

Two details are good to know. The `failed()` method is a notification only: the exception still reaches the code that called `run()`, exactly as before. And a workflow can suspend and resume many times, so your consumer should treat `suspended()` as "this is the current pending request for this runId", replacing any previous one, instead of adding a new entry every time.

### Attaching a channel

You attach a Channel with `setChannel()`. If you do not set one, the workflow uses an internal `NullChannel` that does nothing, so existing code keeps working with no extra cost.

```php
use NeuronAI\Workflow\Channel\CallbackChannel;
use NeuronAI\Workflow\Persistence\FilePersistence;

// Fluent definition
$workflow = MyWorkflow::make()->setChannel(new MyChannel());

// Extending the Workflow
class MyWorkflow extends Workflow
{
    protected function channel(): ?StreamingChannelInterface
    {
        return new CallbackChannel(...);
    }
]

$state = $workflow->run();
```

### CallbackChannel

The fastest way to get started is `CallbackChannel`. It wraps up to four closures, one for each method of the interface. All of them are optional. Here is a complete example that publishes every streamed item to Redis, so a websocket server can forward it to the browser:

```php
use NeuronAI\Workflow\Channel\CallbackChannel;
use NeuronAI\Workflow\Persistence\FilePersistence;

$workflow = MyWorkflow::make()
    ->setChannel(new CallbackChannel(
        onSend: function (object $item) use ($redis): void {
            $redis->publish('thread-123', serialize($item));
        },
    ));

$state = $workflow->run();
```

Now it does not matter if `run()` is called by a controller, a queue worker, or a cron job. The chunks always reach Redis, and from there your frontend.

You can also react to the terminal calls. This example notifies the frontend when the run pauses for approval, completes, or fails:

```php
use NeuronAI\Workflow\Channel\CallbackChannel;
use NeuronAI\Workflow\Interrupt\InterruptRequest;
use NeuronAI\Workflow\WorkflowState;

$channel = new CallbackChannel(
    onSend: fn (object $item) => $redis->publish('thread-123', serialize($item)),
    onSuspended: fn (InterruptRequest $request, string $runId) => $redis->publish(
        'thread-123',
        json_encode(['type' => 'suspended', 'runId' => $runId])
    ),
    onCompleted: fn (WorkflowState $state, string $runId) => $redis->publish(
        'thread-123',
        json_encode(['type' => 'completed', 'runId' => $runId])
    ),
    onFailed: fn (\Throwable $e, string $runId) => $redis->publish(
        'thread-123',
        json_encode(['type' => 'failed', 'runId' => $runId, 'message' => $e->getMessage()])
    ),
);

$workflow->setChannel($channel);
```

### Channel errors never break the run

A Channel talks to external systems, and external systems fail. A Redis server can be down, a socket can be closed. The framework protects the run from this: every call to the Channel is guarded. If your Channel throws an exception, the workflow catches it, reports it as a `ChannelError` event through the observability system, and continues the run as if nothing happened.

This means the AI work is never lost because a delivery transport had a problem. The chat history remains the source of truth. The live stream is only a convenience on top of it, and a client that missed some chunks can always reload the final result from history.

If your transport needs a real policy for repeated failures, for example stop trying after ten errors, put that logic inside your Channel implementation, because only the Channel knows what a failure means for its own transport.

### Writing your own channel

When the callback style is not enough, implement `ChannelInterface` directly. Here is a complete channel that appends everything to a log file:

```php
<?php

declare(strict_types=1);

namespace App\Channels;

use NeuronAI\Workflow\Channel\ChannelInterface;
use NeuronAI\Workflow\Interrupt\InterruptRequest;
use NeuronAI\Workflow\WorkflowState;
use Throwable;

class LogChannel implements ChannelInterface
{
    public function __construct(protected string $path)
    {
    }

    public function send(object $item): void
    {
        $this->write('item: ' . $item::class);
    }

    public function suspended(InterruptRequest $request, string $runId): void
    {
        $this->write("suspended: {$runId}");
    }

    public function completed(WorkflowState $state, string $runId): void
    {
        $this->write("completed: {$runId}");
    }

    public function failed(Throwable $exception, string $runId): void
    {
        $this->write("failed: {$runId} ({$exception->getMessage()})");
    }

    protected function write(string $line): void
    {
        file_put_contents($this->path, $line . PHP_EOL, FILE_APPEND);
    }
}
```

And attach it like any other channel:

```php
$workflow->setChannel(new LogChannel(__DIR__ . '/storage/workflow.log'));
```

A channel instance lives for one run segment, from the start of the run until one terminal call. This means you can safely keep state inside it, like the lazy protocol start in `StreamAdapterChannel`, without worrying about leaks between runs.
