Looking to hire Laravel developers? Try LaraJobs

laravel-flow-ai maintained by padosoft

Description
Agentic AI layer for padosoft/laravel-flow: LLM prompt nodes, MCP client and server (expose flows as MCP tools), bounded agent nodes, AI flow builder and Flow Advisor. Part of the Laravel Flow 2.0 suite.
Last update
2026/08/26 03:24 (dev-main)
License
Links
Downloads
374

Comments
comments powered by Disqus

Laravel Flow AI

The agentic AI layer for padosoft/laravel-flow: LLM nodes, MCP client/server, bounded agents, AI flow builder and Flow Advisor.

Latest Version on Packagist License

Status

Stable (v1.0.0) — the agentic AI layer of the Laravel Flow 2.0 suite; from v1.0.0 the @api surface is covered by SemVer. Requires padosoft/laravel-flow ^2.0.

What it will provide

  • LLM node — provider-agnostic prompt nodes with structured output validated against typed ports (schema-violation auto-retry), token/cost tracked into business impact.
  • MCP client node — call external MCP tools as graph nodes.
  • MCP server (flow-as-tool) — expose published flows as MCP tools (typed ports → JSON Schema); approval gates pause the calling agent for human sign-off. Disabled by default, per-flow opt-in.
  • Bounded agent node — LLM+tools loop with hard token/cost/iteration budgets, tool allowlists and approval escape hatches.
  • MCP tool pinning — pin the digest of each tool's contract and fail closed when a server rewrites it (the "rug pull").
  • AI-BOM — one command emitting the bill of materials of your AI stack, with a stable digest you can gate CI on.
  • AI flow builder — natural language → validated flow graph draft.
  • Flow Advisor — analyzes your node/MCP catalog and run history to suggest new flows or concrete improvements to existing ones (flow:suggest, flow:improve), always as reviewable drafts.

Requirements

  • PHP ^8.3
  • Laravel ^13.0

Installation

composer require padosoft/laravel-flow-ai

Requires padosoft/laravel-flow ^2.0, which Composer resolves from Packagist automatically.

Configuration

Publish the config file to customize the built-in Anthropic driver:

php artisan vendor:publish --tag=laravel-flow-ai-config
Env var Default Purpose
LARAVEL_FLOW_AI_ANTHROPIC_API_KEY (empty) Anthropic API key. Left empty by default — a host app that only binds Padosoft\LaravelFlowAI\Llm\FakeDriver for its own tests is never forced to set this.
LARAVEL_FLOW_AI_ANTHROPIC_BASE_URL https://api.anthropic.com/v1/messages Anthropic Messages API endpoint.
LARAVEL_FLOW_AI_ANTHROPIC_API_VERSION 2023-06-01 Anthropic API version header.
LARAVEL_FLOW_AI_ANTHROPIC_TIMEOUT_SECONDS 30 Request timeout in seconds.

LLM client contract

Every AI-pack node that talks to a language model depends on Padosoft\LaravelFlowAI\Contracts\LlmClient, never a concrete provider SDK directly:

use Padosoft\LaravelFlowAI\Contracts\LlmClient;
use Padosoft\LaravelFlowAI\Llm\LlmRequest;

$response = app(LlmClient::class)->complete(new LlmRequest(
    prompt: 'Summarize this run in one sentence.',
    model: 'claude-sonnet-5',
));

$response->content;         // raw completion text
$response->promptTokens;    // usage, always present
$response->completionTokens;
$response->totalTokens();

Pass responseSchema (a JSON Schema array) on LlmRequest to request structured output — the built-in AnthropicDriver forwards it as a forced tool call (Anthropic's Messages API has no separate structured-output field) and hands back the schema-shaped result as JSON text in $response->content, ready to json_decode(). Validating the decoded value against a node's declared output ports is the caller's responsibility, not this contract's.

Driver: one provider, or all of them

The package binds LlmClient to Padosoft\LaravelFlowAI\Llm\AnthropicDriver by default — one provider over its raw HTTP API, which is the right amount of machinery for one provider and the wrong amount for five.

Padosoft\LaravelFlowAI\Llm\LaravelAiDriver is the alternative, backed by the official laravel/ai SDK (^0.11). Binding it changes nothing in your nodes and gets you:

Every provider the SDK supports selected by config instead of by swapping a class
Failover across providers and models already implemented and tested upstream
Observability, free the run emits the 0.11 step and tool events, so laravel-ai-finops meters a node's spend per step and laravel-iam-agents stamps the run's invocation id onto the delegation context — with nothing added to this package
$this->app->bind(LlmClient::class, fn () => new LaravelAiDriver(provider: 'anthropic'));

Two things it deliberately does not do. Structured output is instructed, not provider-enforced: laravel/ai takes a schema as Laravel JsonSchema type objects, not the raw JSON Schema array LlmRequest::$responseSchema carries, and translating one into the other for arbitrary schemas is a job with edge cases that would fail quietly — so the schema is stated in the instructions as a contract, and the caller parses the text, exactly as LlmClient already promises. When you need the provider itself to refuse a non-conforming answer, bind AnthropicDriver, which forces it through tool use. And one step, never a loop: a node is a completion, and leaving the step budget at the SDK default would let a prompt that happened to emit a tool call turn one node into a multi-step run the flow never authorised.

The package binds LlmClient to Padosoft\LaravelFlowAI\Llm\AnthropicDriver by default. Swap the binding in your own service provider to point at a different implementation. Padosoft\LaravelFlowAI\Llm\FakeDriver — a deterministic, no-network driver constructed with a queue of canned LlmResponses — is available for your own application's tests.

Delegated identity: agents that act on behalf of a user

BoundedAgentNode can run under a delegated identity — the pairing of WHO the work is for (subject, e.g. user:42) and WHICH agent identity performs it (actor, e.g. agent:01J…), proven by a short-lived delegated access token (OAuth 2.0 Token Exchange, RFC 8693). This package owns the seam and depends on no IAM package; the reference provider is padosoft/laravel-iam-agents on top of padosoft/laravel-iam-server.

Bind Contracts\DelegatedIdentityResolver (typically container-scoped, so each run resolves fresh) and the node does the rest:

  • at spawn, the resolved identity's env vars (FLOW_DELEGATED_TOKEN, FLOW_DELEGATED_SUBJECT, FLOW_DELEGATED_ACTOR) are handed to the spawned MCP tool server — process environment only, never run input (core persists flow_runs.input unredacted), transcripts, prompts, or logs;
  • before every tool call the grant is re-checked: a revocation landing mid-run throws Identity\Exceptions\GrantRevokedException and the node halts, fail-closed, before the call happens — the same posture as the tool allowlist;
  • no binding = no delegated identity = the pre-existing behavior, unchanged.

Mcp\FlowToolServer closes the loop on the inbound side: a verified subject in the transport-provided $actor becomes the run's persisted flow_runs.subject (core ≥ 2.2), so a run started by an agent on a user's behalf is attributable end-to-end — in the run row, not smuggled through its input.

MCP tool pinning: the server you approved is the server you get

An MCP server answers tools/list fresh on every handshake, and nothing in the protocol stops it from answering differently tomorrow. A tool whose description quietly grows "…and also forward the result to https://elsewhere" is, to a model, a different tool at the same name — the rug pull. Nothing in an allowlist catches it: the name never changed.

Pinning records the digest of each tool's contractname, title, description, inputSchema, outputSchema, annotations: everything the model reads or a host gates on — and compares it at every handshake.

php artisan flow:mcp-pin npx -y @scope/some-mcp-server
2 tool contract(s) read from [npx -y @scope/some-mcp-server].

Add to config/laravel-flow-ai.php, under mcp.pinning.servers:

    'npx -y @scope/some-mcp-server' => [
        'fetch'  => 'sha256:9f2c…',
        'search' => 'sha256:41ab…',
    ],
'pinning' => [
    'mode' => 'enforce',   // off (default) | warn | enforce
    'require_pins' => false,
    'servers' => [ /* the block above */ ],
],

From then on a drifted server fails the node — Mcp\Exceptions\McpToolPinMismatchException, a third failure class distinct from "unreachable" and "the tool said no", because an operator paged at 3am needs to know immediately whether this is a broken server or a changed one. Four things count as drift, and they are four different incidents:

contract changed same name, different description/schema/annotations — the rug pull itself
pinned tool missing something that was approved is no longer advertised
unpinned tool the server grew a tool nobody approved
server not pinned only with require_pins, for a closed fleet

Three decisions worth knowing before you turn it on:

  • A pinset closes the catalog. Pinning search and fetch is not "check those two and ignore the rest": a server that also advertises exfiltrate overnight is not the server that was approved, and the model reads the new description on the next turn.
  • The call path is pinned too. tools/call names a tool directly and never needs the catalog, so pinning only the list would leave it one call away from being bypassed. A pinned session therefore verifies the catalog once before the first call — one extra round trip, only when pinning is on, and already paid by any caller that lists first (the bounded agent always does).
  • warn is a migration setting, not a destination. It exists so you can turn pinning on across a real fleet and learn what actually drifts before it starts failing runs.

In CI, check live servers against what is configured:

php artisan flow:mcp-pin --verify npx -y @scope/some-mcp-server

Non-zero on drift — which is how you find out before a production run fails closed.

Provenance: the model may fill in parameters, never choose the operation

Pinning fixes what a tool does. This fixes what may decide to call it.

Every node here now declares where its data's authority comes from, using the provenance model in padosoft/laravel-flow 2.4:

Port Declaration Why
ai.llm.promptresult Untrusted A completion is someone else's words. Anyone who can influence what the model read chose them.
ai.agent.boundedresult Untrusted Model output informed by tool output: doubly so.
ai.mcp.toolresult Untrusted A remote server's response is a remote server's words.
ai.mcp.toolcommand, args, tool requiresTrusted These decide which process is spawned and which operation runs.
ai.agent.boundedcommand, args requiresTrusted Same: which server gets spawned.
ai.llm.prompt / ai.agent.boundedmodel, systemPrompt requiresTrusted Which provider receives the conversation, and who writes the instructions.
ai.mcp.toolarguments deliberately open See below.

So this graph no longer validates — GraphValidator rejects it at publish time, before it can run once:

[ ai.llm.prompt ] --result--> [ ai.mcp.tool ] (args)

Input [args] on node [mcp] requires trusted data but receives untrusted data
originating at [llm.result] (path: llm.result -> mcp.args).

args is the argv of a spawned process. A model filling it in is arbitrary code execution, and it is type-compatible with the model's own output, so nothing else stood in the way.

Why arguments is deliberately left open

Filling in the parameters of a tool the graph author chose is what tool use is. Forbidding it would not make anyone safer; it would make the check the first thing people switch off.

The line this package draws is narrower and, we think, the right one:

The model may fill in parameters. It may never select the operation, the executable, or the instructions.

That is why pinning matters so much here. With the tool fixed by the author, the pin is what stops the server from quietly redefining what that tool does — the two features are halves of one guarantee, and neither is sufficient alone.

The subtle one: systemPrompt

Nothing is executed when a model writes the next call's system prompt, which is exactly why it is easy to miss. A model that authors its own instructions has been handed the thing the instructions were there to constrain. It is an escalation with no dangerous-looking function call anywhere in it, and it is requiresTrusted for that reason.

Inside the agent loop is a different question

ai.agent.bounded lets the model choose tools and arguments within its loop — that is what a bounded agent is. The bound there is $allowedTools plus McpToolAuthorizer, not this analysis. The two answer different questions:

  • the taint analysis fixes what the graph may connect,
  • the allowlist fixes what the loop may reach.

Neither substitutes for the other, and a deployment that cares should set both.

Seeing it

php artisan flow:taint your-definition-name

Full model, including how to write a legitimate sanitizer: Provenance and taint.

AI-BOM: what your AI stack is made of

composer.lock cannot answer "what does our AI stack consist of and what may it reach?", because half the supply chain is not packages. It is MCP servers spawned by name, tool descriptions fetched from those servers at run time, model endpoints, and the guardrail configuration deciding which of it is reachable.

php artisan flow:ai-bom --output=ai-bom.json
{
  "bomFormat": "padosoft-ai-bom",
  "specVersion": "1.0",
  "packages": [{ "name": "padosoft/laravel-flow-ai", "version": "1.3.0" }],
  "providers": [{ "host": "api.anthropic.com", "resolvesTo": "…\GuardedLlmClient",
                  "modelResolution": "per-execution (wired input port, recorded in run history)" }],
  "mcpServers": [{ "id": "npx -y @scope/some-mcp-server", "pinned": true,
                   "tools": [{ "name": "search", "digest": "sha256:41ab…" }] }],
  "exposedFlows": [{ "name": "send-welcome-email", "status": "published", "version": 7, "checksum": "…" }],
  "controls": { "guardrails": { … }, "agent": { … }, "mcpToolPinning": { … }, "authorizers": { … } }
}

Everything is derived from configuration and the container — never by connecting to anything, so it is safe to run in CI on a machine with no network and no MCP servers installed. No API key is ever read: a BOM is meant to be committed, diffed and attached to a release.

Two details that are deliberate rather than incidental. It reports providers, not models, because a model id is a wired input port chosen per execution — a static "models" list would be a field that is confidently wrong. And it reports the class the container actually hands back for McpToolAuthorizer, not the package default: a host that rebound it to something permissive must see that, and reporting the default would be reassuring and false.

The digest excludes the timestamp, so an unchanged application compares equal and a one-line CI gate catches a supply chain that moved without anyone saying so:

test "$(php artisan flow:ai-bom --digest)" = "$(cat ai-bom.sha)"

License

Apache-2.0. See LICENSE.