laravel-flow-ai maintained by padosoft
Laravel Flow AI
The agentic AI layer for padosoft/laravel-flow: LLM nodes, MCP client/server, bounded agents, AI flow builder and Flow Advisor.
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 persistsflow_runs.inputunredacted), transcripts, prompts, or logs; - before every tool call the grant is re-checked: a revocation landing mid-run throws
Identity\Exceptions\GrantRevokedExceptionand 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 contract — name, 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
searchandfetchis not "check those two and ignore the rest": a server that also advertisesexfiltrateovernight 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/callnames 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). warnis 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.prompt → result |
Untrusted |
A completion is someone else's words. Anyone who can influence what the model read chose them. |
ai.agent.bounded → result |
Untrusted |
Model output informed by tool output: doubly so. |
ai.mcp.tool → result |
Untrusted |
A remote server's response is a remote server's words. |
ai.mcp.tool ← command, args, tool |
requiresTrusted |
These decide which process is spawned and which operation runs. |
ai.agent.bounded ← command, args |
requiresTrusted |
Same: which server gets spawned. |
ai.llm.prompt / ai.agent.bounded ← model, systemPrompt |
requiresTrusted |
Which provider receives the conversation, and who writes the instructions. |
ai.mcp.tool ← arguments |
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.