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
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:
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:
<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
| Surface | What you get |
|---|---|
| Web | POST /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). |
| CLI | php artisan actions:run create-post title=Hi body=… --as=1 |
| TypeScript | createPost() with typed input and output, in the file php artisan actions:typescript writes |
| Agents | a tool in the default toolset, after composer require laravel/ai |
| MCP | a tool on the package's MCP server, for a token with the actions:write ability (MCP) |
| Queue | CreatePost::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.
composer require agentic-actions/laravel:^0.9@beta
php artisan actions:installactions: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:
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:
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:
php artisan actions:check --updateThat 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:
"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:
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
<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>
@endifOn 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:
$token = $user->createToken('importer', ['actions:read', 'actions:write'])->plainTextToken;| Ability | Reaches |
|---|---|
actions:read | Read actions |
actions:write | Write actions |
actions:destructive | Destructive actions |
actions:external | External actions |
tenant:{id} | only that tenant, by its primary key; the token then reaches no action outside a tenant |
From another application:
$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
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."}'| Case | Status | Body |
|---|---|---|
| Success | 200 | {"id": 1, "title": "Hello"}: only the keys outputSchema() declares, or {} |
| Invalid input | 422 | {"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 field | 409, or the status it sets | {"message": "…", "code": "…", "details": {}} |
Token without the ability, not a member of the tenant, shouldRegister() said no | 404 | {"message": "Not found."} |
| No such action on the web (unknown, or not exposed there) | 404 | your app's usual 404: no route matches |
authorize() said no | 403 | {"message": "You are not allowed to do this."} |
| No user | 401 | {"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
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).