Skip to content

Getting started ​

Beta. 0.9.0-beta.2 is the current release. The API can still change before 1.0: the changelog lists every change and how to upgrade.

Write an operation once, as an Action class. Mark it #[Expose] and the same class answers a web route (JSON and browser forms, with Precognition), an Artisan command, a tool call from a laravel/ai agent or an MCP client, and a typed TypeScript function. Every caller goes through one pipeline: exposure, token abilities, tenant membership, authorize(), validation, handle(), and an allowlist on the output.

php
<?php

namespace App\Actions;

use AgenticActions\Action;
use AgenticActions\ActionContext;
use AgenticActions\Attributes\Expose;
use AgenticActions\Effect;
use AgenticActions\Refusal;
use App\Models\Post;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\ValidatedInput;

#[Expose]
final class CreatePost extends Action
{
    protected string $description = 'Create a draft blog post. Publishing is a separate action.';

    protected ?Effect $effect = Effect::Write;

    protected array $touches = ['posts'];

    protected bool $tenantScoped = false; // Only matters once config('agentic-actions.tenant.model') is set.

    /**
     * The post's fields.
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()->max(120)->required(),
            'body' => $schema->string()->required(),
            'excerpt' => $schema->string()->max(200)->nullable()->description('One line for listings. Left empty when omitted.'),
        ];
    }

    /**
     * What the caller gets back. Keys not declared here never leave the server.
     */
    public function outputSchema(JsonSchema $schema): array
    {
        return [
            'id' => $schema->integer()->required(),
            'title' => $schema->string()->required(),
        ];
    }

    /**
     * Any signed-in author may draft a post.
     */
    public function authorize(ActionContext $context): bool
    {
        return $context->actor instanceof User;
    }

    /**
     * Save the draft.
     */
    public function handle(ActionContext $context, ValidatedInput $input): Post
    {
        $author = $context->actor(User::class);

        if ($author->posts()->where('title', $input->string('title')->toString())->exists()) {
            throw Refusal::make(__('You already have a post with that title.'))->on('title');
        }

        return $author->posts()->create([...$input->all(), 'status' => 'draft']);
    }
}

That class is already a JSON endpoint:

bash
curl https://example.com/api/actions/create-post \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "body": "My first post."}'

{"id":1,"title":"Hello"}

And a plain Blade form can post to it:

blade
<form method="POST" action="{{ route('actions.create-post') }}">
    @csrf
    <input name="title" value="{{ old('title') }}">
    <textarea name="body">{{ old('body') }}</textarea>
    <button>Save draft</button>
</form>

What that one class gets ​

SurfaceWhat you get
WebPOST /actions/create-post, named actions.create-post, for JSON callers and browser forms, with Precognition. Mount it in as many route groups as you need (web, api., a tenant group).
CLIphp artisan actions:run create-post title=Hi body=… --as=1
TypeScriptcreatePost() with typed input and output, in the file php artisan actions:typescript writes
Agentsa tool in the default toolset, after composer require laravel/ai
MCPa tool on the package's MCP server, for a token with the actions:write ability (MCP)
QueueCreatePost::dispatch($input, $context) runs the same pipeline in a worker, as the caller

An action that removes something other people rely on (Effect::Destructive) or reaches outside the app (Effect::External) gets the web route and the CLI. MCP clients never reach it. An agent reaches it only when #[Expose(agents: [...])] names a toolset, and each call then waits for the person to confirm it on a card the server builds: see confirmations. A Read or Write action with $askForMissing asks the person, in a form in the chat, for the fields a model's call left out, instead of refusing the call: see asking the person.

Installation ​

You need PHP 8.3 or later and Laravel 12.62+ or 13.15+. laravel/ai 1.x is optional, for agent tools.

bash
composer require agentic-actions/laravel:^0.9@beta
php artisan actions:install

actions:install asks which features your app uses (web routes, agents and a copilot, confirmations, MCP, tenants), publishes the config and the migrations those features need, prints the route lines, and asks before it migrates. Run it again after adding a feature. Setup lists what each feature needs and what the package depends on.

Mount the generated routes inside your own middleware, in routes/web.php:

php
use AgenticActions\Facades\Actions;

Route::middleware('auth')->group(fn () => Actions::routes());

For token clients, run php artisan install:api, add the HasApiTokens trait to your User model as it asks, then in routes/api.php:

php
use AgenticActions\Facades\Actions;

Route::middleware('auth:sanctum')->name('api.')->group(fn () => Actions::routes());

Until one of these lines exists, php artisan actions:list prints both whenever an action is open on the web.

Then record what your actions expose:

bash
php artisan actions:check --update

That writes actions.exposure.json, which you commit. From then on actions:check fails whenever an action's exposure changes (a new route, a new toolset, another effect) until someone reviews the diff and runs --update again. Run php artisan actions:check in CI, or inside your test suite.

For the TypeScript client, install the npm package from the Composer package, so the two versions always match. In package.json:

json
"dependencies": {
    "@agentic-actions/client": "file:vendor/agentic-actions/laravel/js"
}

The client is also on npm (npm install @agentic-actions/client); install the same version as the Composer package. Run npm install, then php artisan actions:typescript, which writes resources/js/agentic/actions.ts.

Installed that way, the client's imports of React and Inertia resolve to your app's own copies. When Composer installs the package from a local path instead (a path repository, which symlinks a checkout that has its own js/node_modules), Vite follows the symlink and can load a second React or Inertia: React reports an invalid hook call, or the client reloads through a router that is not your app's. Dedupe them in vite.config.ts:

ts
export default defineConfig({
    resolve: {
        dedupe: ['react', 'react-dom', '@inertiajs/core', '@inertiajs/react'],
    },
    // ...
});

php artisan make:agentic-action CreatePost writes a new action that is discovered but exposed nowhere, whose authorize() returns false until you decide who may run it. Actions are discovered under app/; php artisan vendor:publish --tag=agentic-actions-config publishes the config if yours live elsewhere. The tags agentic-actions-lang and agentic-actions-stubs publish the sentences callers and agents read, and the stub make:agentic-action writes from.

Calling an action ​

Blade ​

blade
<form method="POST" action="{{ route('actions.create-post') }}">
    @csrf

    <input name="title" value="{{ old('title') }}">
    @error('title') <p>{{ $message }}</p> @enderror

    <textarea name="body">{{ old('body') }}</textarea>
    @error('body') <p>{{ $message }}</p> @enderror

    @error('action') <p>{{ $message }}</p> @enderror

    <button>Save draft</button>
</form>

@if (session('action'))
    <p>Saved {{ session('action')['output']['title'] }}.</p>
@endif

On success the visitor is redirected back with a 303, and session('action') holds ['name' => 'create-post', 'output' => ['id' => 1, 'title' => 'Hello']]. Override redirectTo() on the action to send them somewhere else. Invalid input comes back the way it does from a form request: errors in the default bag (or the action's $errorBag), and the old input without your exception handler's dontFlash keys. The duplicate-title refusal lands on title because of ->on('title'); a refusal without a field lands on action.

Token client ​

A Sanctum token calls the api. mount. Sanctum's default token (['*']) passes the ability check for every effect. To narrow one, grant an ability per effect:

php
$token = $user->createToken('importer', ['actions:read', 'actions:write'])->plainTextToken;
AbilityReaches
actions:readRead actions
actions:writeWrite actions
actions:destructiveDestructive actions
actions:externalExternal actions
tenant:{id}only that tenant, by its primary key; the token then reaches no action outside a tenant

From another application:

php
$post = Http::withToken($token)
    ->acceptJson()
    ->post('https://example.com/api/actions/create-post', [
        'title' => 'Hello',
        'body' => 'My first post.',
    ])
    ->throw()
    ->json();

A token without the ability gets a 404, the status an action that does not exist gets. The web mount never reads a bearer token. A guard that is neither the session nor Sanctum reads as having no abilities until you bind your own reader (recipe), and php artisan actions:list shows how each configured guard is read.

curl ​

bash
curl https://example.com/api/actions/create-post \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"title": "Hello", "body": "My first post."}'
CaseStatusBody
Success200{"id": 1, "title": "Hello"}: only the keys outputSchema() declares, or {}
Invalid input422{"message": "…", "errors": {"title": ["…"]}}
A refusal on a field (the same title twice)422{"message": "…", "errors": {"title": ["You already have a post with that title."]}}
A refusal without a field409, or the status it sets{"message": "…", "code": "…", "details": {}}
Token without the ability, not a member of the tenant, shouldRegister() said no404{"message": "Not found."}
No such action on the web (unknown, or not exposed there)404your app's usual 404: no route matches
authorize() said no403{"message": "You are not allowed to do this."}
No user401{"message": "Unauthenticated."}

Idempotency-Key is optional. An action that needs one calls $context->requireIdempotencyKey(), which answers 428 when the header is missing. Send Precognition: true to validate without running the action.

Livewire ​

php
<?php

namespace App\Livewire;

use AgenticActions\ActionContext;
use AgenticActions\Refusal;
use App\Actions\CreatePost;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

final class CreatePostForm extends Component
{
    public string $title = '';

    public string $body = '';

    public function save(): void
    {
        try {
            CreatePost::run(['title' => $this->title, 'body' => $this->body], ActionContext::http(Auth::user()));
        } catch (Refusal $r) {
            throw $r->toValidationException();
        }

        $this->redirectRoute('posts.index');
    }
}

run() goes through the same pipeline in-process. It returns what handle() returned, throws a ValidationException for invalid input (Livewire shows it as field errors), and throws a Refusal for anything else that stopped the call. toValidationException() puts the refusal's message on its field, here title, or on action. The same three lines work in a Blade controller.

Other front ends ​

@agentic-actions/client has no dependencies. callAction(createPost(), { title, body }) posts JSON with an Idempotency-Key and, on the same origin, the XSRF header; it resolves the typed output or throws ActionValidationError, ActionRefusedError or ActionFailedError. After each success it hands the action's $touches (here ['posts']) to every handler registered with onTouched(), so your own store or cache can refetch what went stale. On Inertia, @agentic-actions/client/inertia reloads the props those keys name, and @agentic-actions/client/react wraps Inertia's useHttp in useAction() (recipe).

Released under the MIT License.