Skip to content

Pipelines and Graphs

Two building blocks for work that takes more than one call. A pipeline runs steps in a straight line: fill a prompt, ask the agent, clean the answer. A graph runs nodes that can loop, branch, stop for a human and continue later. Both use the agent you already built, here called $claw.

BlockUse it whenEntry point
Pipelinethe steps are always the same, in the same orderPipeline::make()
Graphthe next step depends on a result, a human must approve, or work splits into branchesnew Graph('name')

use PhpClaw\Pipeline\Pipeline;
use PhpClaw\Pipeline\Steps\ClawStep;
use PhpClaw\Pipeline\Steps\PromptStep;
$capital = Pipeline::make()
->pipe(new PromptStep('Reply with one word only: the capital city of {country}.'))
->pipe(new ClawStep($claw))
->pipe(fn (string $reply): string => trim($reply, " .\n"));
$capital->invoke(['country' => 'France']);
// "Paris"
$capital->batch(['jp' => ['country' => 'Japan'], 'it' => ['country' => 'Italy']]);
// ['jp' => 'Tokyo', 'it' => 'Rome']

Each step’s output is the next step’s input. pipe() returns a new pipeline and leaves the one you called it on unchanged, so a base pipeline can be extended safely. A plain callable becomes a TransformStep.

StepInputOutput
PromptStep($template)an array of valuesthe template with every {name} filled
ClawStep($claw)textthe agent’s reply text
StructuredStep($claw, $schema)textthe reply as an array, validated against the JSON Schema
TransformStep($callable) or a callableanythingwhat the callable returns

batch() runs every input in turn and keeps the keys; the first input that fails stops the batch.

stream() runs every step but the last, then streams the last step’s reply. The last step must be a ClawStep; anything else throws PipelineException.

$sentence = Pipeline::make()
->pipe(new PromptStep('Write one sentence about {topic}.'))
->pipe(new ClawStep($claw))
->stream(['topic' => 'PHP'], function (string $token): void {
echo $token;
});

Wrap any step:

use PhpClaw\Pipeline\Steps\FallbackStep;
use PhpClaw\Pipeline\Steps\RetryStep;
$reliable = new RetryStep(new ClawStep($claw), attempts: 3);
$safe = new FallbackStep(new ClawStep($claw), [new ClawStep($backupClaw)]);
StepBehaviourDefault errors
RetryStep($step, attempts, retryOn)runs the step again on a listed error, up to attempts runs in total (default 3), with no wait between themProviderException, StructuredOutputException
FallbackStep($step, $fallbacks, fallbackOn)on a listed error, tries each fallback in order and returns the first output; when all fail, it throws the main step’s errorProviderException, StructuredOutputException

Any other error is thrown straight away. The agent retries a failed provider call only when you set maxRetries() (default 0), and RetryStep adds attempts on top of those.

PipelineTool lets an agent call a whole pipeline as one tool:

use PhpClaw\Claw;
use PhpClaw\Pipeline\PipelineTool;
$tool = new PipelineTool(
$capital,
'capital_city',
'Return the capital city of a country.',
['type' => 'object', 'properties' => ['country' => ['type' => 'string']], 'required' => ['country']],
);
$agent = Claw::builder()->addTool($tool)->build();

The tool input becomes the pipeline input. Text output is returned as is; anything else is encoded as JSON. A PipelineException, PromptTemplateException or StructuredOutputException becomes a tool error the model can react to; any other error stops the run.


A node gets the current GraphState and returns it. Edges decide what runs next.

use PhpClaw\Graph\Graph;
use PhpClaw\Graph\GraphState;
$review = (new Graph('writer-reviewer'))
->addNode('write', fn (GraphState $s): GraphState => $s
->set('draft', trim($claw->send('Write one sentence about '.$s->get('topic').'.')->text))
->set('drafts', $s->get('drafts', 0) + 1))
->addNode('review', fn (GraphState $s): GraphState => $s
->set('ok', str_contains(strtoupper($claw->send('Is this one complete English sentence? Answer only YES or NO: '.$s->get('draft'))->text), 'YES')))
->setEntry('write')
->addEdge('write', 'review')
->addConditionalEdge('review', fn (GraphState $s): string => $s->get('ok') || $s->get('drafts') >= 3 ? Graph::END : 'write');
$final = $review->run(new GraphState(['topic' => 'PHP']));
$final->get('draft');
MethodMeaning
addNode($name, $node)$node gets (GraphState $state, GraphRuntime $runtime) and returns the state, a GraphCommand, or nothing
addEdge($from, $to)always go from one node to another
addConditionalEdge($from, $router)$router gets the state and returns the next node’s name or Graph::END
setEntry($name)the node the walk starts from
run($state, maxSteps, context, threadId)walk from the entry node until a route reaches Graph::END, and return the final state
validate()check that the entry, every edge and every pause name an added node; run() calls it first

A conditional edge wins over a plain edge from the same node. A node with neither ends the walk.

Loop guard: a walk that runs more than maxSteps nodes (default 50) stops with GraphException, “Graph stopped after N steps without reaching Graph::END.”

Return a GraphCommand to set values and pick the next node at once:

use PhpClaw\Graph\GraphCommand;
->addNode('send', fn (GraphState $s): GraphCommand => new GraphCommand(
goto: Graph::END,
update: ['sent' => $s->get('approved') === 'yes'],
))
->addNode('fetch', $fetch, attempts: 3, onError: fn (GraphState $s, \Exception $e, string $node): GraphState => $s->set('failed', $node))
->setNodeDefaults(attempts: 2)

attempts counts the first run. retryOn lists the errors that cause another attempt (by default ProviderException and StructuredOutputException). When the last attempt fails, onError gets the state, the error and the node name, and its return value is used like the node’s own. Without an onError, the error is thrown. setNodeDefaults() sets these for every node that does not set its own.

$graph->setStore($memory)->run($state, context: ['user_id' => 7]);

Every node gets a GraphRuntime as its second argument: $runtime->context holds the run’s context, $runtime->store the memory driver passed to setStore() (or null), and $runtime->runId the graph run’s id.

Give the graph a checkpointer (any memory driver) and run it under a thread id. The state is saved after every node. A node calls $runtime->interrupt($value) to stop and wait:

use PhpClaw\Exceptions\GraphInterruptedException;
use PhpClaw\Graph\GraphRuntime;
$refund = (new Graph('refund'))
->setCheckpointer($memory)
->addNode('draft', fn (GraphState $s): GraphState => $s
->set('draft', trim($claw->send('Reply with one short sentence offering a refund for order '.$s->get('order').'.')->text)))
->addNode('approve', fn (GraphState $s, GraphRuntime $rt): GraphState => $s
->set('approved', $rt->interrupt(['question' => 'Approve this refund?', 'draft' => $s->get('draft')])))
->addNode('send', fn (GraphState $s): GraphCommand => new GraphCommand(goto: Graph::END, update: ['sent' => $s->get('approved') === 'yes']))
->setEntry('draft')
->addEdge('draft', 'approve')
->addEdge('approve', 'send');
try {
$refund->run(new GraphState(['order' => 'A-1042']), threadId: 'refund-A-1042');
} catch (GraphInterruptedException $e) {
// $e->node is 'approve'; $e->value is the array passed to interrupt()
}
// Later, in any process:
$final = $refund->resume('refund-A-1042', 'yes');
$final->get('sent'); // true

On resume the paused node runs again, and this time interrupt() returns the answer given to resume().

MethodMeaning
setCheckpointer($memory)save a snapshot after every node; any MemoryInterface driver
interruptBefore(...$nodes), interruptAfter(...$nodes)pause before or after the named nodes without changing them
resume($threadId, $answer)continue a paused thread from its saved snapshot
getState($threadId)the newest GraphSnapshot, or null
getStateHistory($threadId)every snapshot, oldest first
updateState($threadId, $values)write values into the newest snapshot, for example a human’s edit before resume()

Saved state must be plain data: null, scalars, and arrays of those. Anything else throws GraphException when it is saved. Snapshots are kept under the graph_threads memory namespace.

addFanOut() splits the state into branches, runs a branch graph once for each, and hands every result to a join node:

$translateOne = (new Graph('translate-one'))
->addNode('translate', fn (GraphState $s): GraphState => $s
->set('text', trim($claw->send('Translate to '.$s->get('lang').', reply with only the translation: '.$s->get('source'))->text)))
->setEntry('translate');
$translateAll = (new Graph('translate-all'))
->addFanOut(
'split',
fn (GraphState $s): array => array_map(
fn (string $lang): GraphState => new GraphState(['lang' => $lang, 'source' => $s->get('source')]),
['French', 'German', 'Spanish'],
),
$translateOne,
join: 'collect',
)
->addNode('collect', fn (GraphState $s): GraphState => $s->set('summary', implode(' | ', array_map(
fn (array $result): string => $result['ok'] ? $result['values']['text'] : 'FAILED',
$s->get('split'),
))))
->setEntry('split');
$translateAll->run(new GraphState(['source' => 'Thank you for your order']))->get('summary');

The join node finds the results under the fan-out node’s name, in declared order. Each result is ['ok' => true, 'values' => [...]] or ['ok' => false, 'error' => <class>, 'message' => <text>], so one failed branch does not stop the others. A branch graph may not fan out again, and at most 32 branches are allowed (maxBranches).

Without a thread id the branches run one after another in the same request. With a checkpointer and a thread id, run() saves each branch as a queued thread and stops with GraphInterruptedException; workers then finish the job:

$report = $translateAll->drain(limit: 5);
// $report->completed, $report->suspended, $report->failed, $report->skipped

drain() runs up to limit queued threads (default 5) and stops starting new ones after timeBudgetSeconds (default 20). It runs branches first, then the join node once every branch of a parent has finished. Call it from cron, a queue worker or a CLI command. Before running a thread it marks the thread as running and skips any thread that is no longer queued; that mark is a read and a write through the memory driver, not a lock, so run one worker at a time per graph to be sure each branch runs once. The graph needs a name for drain() to find its threads, and a branch cannot pause for a human: an interrupt() call inside a queued branch fails that branch.


A graph run fires graph.start (with entry_node) and, when it reaches Graph::END, graph.end (with steps and last_node). A fan-out fires graph.fan_out and graph.join. An agent called from a node carries the graph’s run id as its parent_run_id, so traces nest. The events carry node names and counts, never the graph state. See Hooks and Cloud.

ExceptionWhen
PipelineExceptiona step gets input it cannot use, or stream() ends in a step that cannot stream
GraphExceptionno entry node, an unknown node, the step limit, a checkpointer, thread id or graph name missing, a thread that is unknown or not paused, a value that cannot be saved, a nested fan-out or too many branches
GraphInterruptedExceptiona saved run paused; $e->threadId, $e->node and $e->value say where and why
  • Building Blocks: prompt templates, structured output, fallback and durable runs for one agent
  • Hooks: the graph.* events
  • Memory: drivers you can pass to setCheckpointer() and setStore()