The TypeScript client
@agentic-actions/client calls your actions from the browser with typed input and output. php artisan actions:typescript writes one typed definition per action route, and the client runs it: callAction() from any front end, useAction() on Inertia React. The same package reads the copilot's chat stream, which the copilot covers.
How to install it is in Getting started, and what each feature needs is in Setup.
Entry points
| Import | Needs | What it holds |
|---|---|---|
@agentic-actions/client | nothing | callAction(), action(), uri(), newKey(), xsrfToken(), the error classes, onTouched() and notifyTouched(); for the copilot without React, the readers of a message's parts and createActionSync() (without React), and viewsOf(), formatCell() and refreshView() for tables (on the page) |
@agentic-actions/client/inertia | @inertiajs/core 3 | installInertiaReload(), reloadTouched() and inertiaApply() |
@agentic-actions/client/react | @inertiajs/react 3 and React 19 | useAction(); for the copilot, useActionSync(), useActionEdits(), <ActionActivity>, <ApprovalCard> and <ElicitationForm> (the panel) |
@agentic-actions/client/views | React 19 | <ActionTable>, and the root's table functions again (on the page) |
@agentic-actions/client/ai-sdk | ai 7, and @ai-sdk/react for useChat | actionsChat(), actionsTransport(), answerElicitation(), turnOutcome() and refusalMessage() (the copilot) |
Importing /react installs the Inertia reload described below. No other entry does anything on import.
The generated file
php artisan actions:typescript writes resources/js/agentic/actions.ts (the typescript.path config key moves it). Commit it, run the command again after you change an action's schema, description, touches or routes, and run php artisan actions:typescript --check in CI: it fails while the committed file differs from what the command would write. While routes are cached, the command refuses and asks for php artisan route:clear, because cached routes hide new actions.
For an action named create-post mounted by Actions::routes(), the file holds:
// Generated by `php artisan actions:typescript` from agentic-actions/laravel 0.9.0-beta.3. Do not edit.
import { action, type ActionDefinition } from '@agentic-actions/client';
export type CreatePostInput = {
title: string;
body: string;
/** One line for listings. Left empty when omitted. */
excerpt?: string | null;
};
export type CreatePostOutput = {
id: number;
title: string;
};
/** Create a draft blog post. Publishing is a separate action. */
export const createPost = (): ActionDefinition<CreatePostInput, CreatePostOutput> =>
action({ name: 'create-post', method: 'post', url: '/actions/create-post', touches: ['posts'] });- The export is the action's name in camel case (
createPost), and its types take the name in studly case:CreatePostInputfromschema()andCreatePostOutputfromoutputSchema(). A field the schema does not require is optional. An empty input or output isRecord<string, never>. - The doc comments are the action's and the fields' descriptions.
touchesis the action's$touches.- Every definition uses POST, and the file exports only routes that accept POST and whose controller is an action.
- The import gains
urionce any route has parameters, as in routes with parameters.
Routes with parameters
An action in a tenant group, such as Route::middleware('auth')->prefix('teams/{team}')->name('teams.')->group(fn () => Actions::routes(tenant: true)), takes its route parameters, typed as {Name}Params:
export type ListTeamPostsParams = { team: string | number };
export type ListTeamPostsInput = Record<string, never>;
export type ListTeamPostsOutput = {
posts: Array<{
id: number;
title: string;
}>;
};
/** List the current team's posts, newest first. */
export const listTeamPosts = (params: ListTeamPostsParams): ActionDefinition<ListTeamPostsInput, ListTeamPostsOutput> =>
action({ name: 'list-team-posts', method: 'post', url: uri('/teams/{team}/actions/list-team-posts', params), touches: [] });Pass the value the URL takes, which is the tenant's route key: listTeamPosts({ team: 'acme' }) for a team whose route key is its slug (tenants). A route parameter is left out of {Name}Input, since the route gives it, and an optional segment ({mode?}) is an optional key.
Which route names the URL
An action has one export, whatever the number of groups that mount it. With several generated mounts, the URL is the first one in the web middleware group, else the first one registered, so an app with both the web and the api. mount gets /actions/create-post, never /api/actions/create-post. To call the api. mount from a browser, write the definition yourself, as in action() and uri().
A hand-written route whose controller is an action, such as Route::post('posts/{post}/archive', ArchivePost::class)->name('posts.archive'), is exported under the action's name (archivePost) when the action has no generated route. Each further named route to the same action is exported under its route name, dots read as words: api.posts.archive becomes apiPostsArchive, with ApiPostsArchiveInput and the rest. An unnamed further route is not exported. When two exports would take one name, or a name is not one TypeScript can declare (a reserved word, or action and uri, which the file imports), the command fails and names the action or route to rename.
callAction()
import { ActionRefusedError, ActionValidationError, callAction } from '@agentic-actions/client';
import { createPost, listTeamPosts } from '@/agentic/actions';
try {
const post = await callAction(createPost(), { title: 'Hello', body: 'My first post.' });
const { posts } = await callAction(listTeamPosts({ team: 'acme' }), {});
} catch (error) {
if (error instanceof ActionValidationError) {
showErrors(error.errors); // { title: 'You already have a post with that title.' }
} else if (error instanceof ActionRefusedError) {
showMessage(error.message);
} else {
throw error;
}
}callAction(definition, input, options?) sends one request and resolves the output the server sent, typed as {Name}Output. It never retries. Each request carries:
Accept: application/jsonandX-Requested-With: XMLHttpRequest;- an
Idempotency-Key: the one you pass, else a fresh UUID for each call; X-XSRF-TOKENfrom theXSRF-TOKENcookie, when the URL has the page's own origin orcredentialsis'include';- the input as JSON. When the input holds a
FileorBlob, at any depth, the call goes asmultipart/form-datainstead, with nested keys in PHP's bracket form (tags[0],author[name]), booleans as1and0,nullas an empty string and aDateas its ISO string.
| Option | What it does |
|---|---|
idempotencyKey | The key to send. Pass the same one when you retry a call yourself, so an action that reads it with requireIdempotencyKey() sees the retry as the same call. The package itself does not replay or drop a repeated key (Idempotency-Key). |
headers | Extra headers, merged over the ones above, such as Authorization for a token. |
credentials | 'same-origin' by default. 'include' is for a Sanctum SPA on another origin. |
signal | An AbortSignal, to cancel the request. |
fetch | The fetch to use, for tests and runtimes without a global one. |
A definition with the method GET or HEAD throws an Error before anything is sent, since the input travels as the body. A network failure or an abort rejects with fetch's own error.
Errors
Every answer that is not a success throws a subclass of ActionError:
| Class | When | Fields |
|---|---|---|
ActionError | the base class of the three below | message: the body's message, or ''; status: the HTTP status |
ActionValidationError | 422 with errors: invalid input, or a refusal on a field | errors: the first message for each key, such as { title: '…' } |
ActionRefusedError | any other 4xx, such as 401, 403, 404, 409, 419 or 428 | code and details when the action refused: the key or sentence it passed to Refusal::make(), and its details() ({} when it set none). The package's fixed answers, such as 403 and 404, carry only message. |
ActionFailedError | 5xx, a body that is not JSON, or any other status | no others; message is '' unless the body had one |
The statuses and bodies are those of the status table, and refusals says how an action sets its own.
action() and uri()
action() builds a definition by hand, for a URL the generated file does not name. The input and output types can come from the generated file. A browser page that calls the api. mount with a token:
import { action, callAction } from '@agentic-actions/client';
import type { CreatePostInput, CreatePostOutput } from '@/agentic/actions';
const createPostWithToken = action<CreatePostInput, CreatePostOutput>({
name: 'create-post',
method: 'post',
url: '/api/actions/create-post',
touches: ['posts'],
});
const post = await callAction(createPostWithToken, { title: 'Hello', body: 'My first post.' }, {
headers: { Authorization: `Bearer ${token}` },
});method is 'post', 'put', 'patch' or 'delete'; TypeScript refuses 'get'. A definition is also a plain { url, method } pair, so Inertia's <Form action>, useForm and useHttp accept it as it is.
uri(template, params) fills {name} and {name?} segments, URL-encoding each value. It throws when a required parameter is missing, or when a value is . or .., which would move the call to another route. An absent optional segment is dropped with its slash. newKey() returns a v4 UUID, for an idempotency key you keep across retries, and xsrfToken() returns the XSRF-TOKEN cookie's value, or '' where there is none.
Touches
After each success, callAction() hands the definition's touches to every handler registered with onTouched(), so your own store or cache can refetch what went stale:
import { onTouched } from '@agentic-actions/client';
const stop = onTouched((touches, definition) => {
if (touches.includes('*') || touches.includes('posts')) {
refetchPosts();
}
});
// Later, when the page goes away:
stop();A handler receives the keys and the definition, or null for touches that come with none, such as a copilot row's. A call whose touches are empty calls no handler. A handler that throws does not stop the others: its error is thrown again on its own, after they have run. useAction() notifies the handlers too, and so does createActionSync() without an apply(). useActionSync() reloads through Inertia instead and calls no handler: give it an apply() that calls notifyTouched() when your store must hear the copilot's writes. For a request you send yourself, call notifyTouched(definition.touches, definition) after it succeeds.
Inertia without React
installInertiaReload() from @agentic-actions/client/inertia registers one touches handler that reloads the props the keys name. The /react entry calls it on import. A Vue or Svelte Inertia app calls it once itself, in the file that calls createInertiaApp(), such as resources/js/app.ts:
import { installInertiaReload } from '@agentic-actions/client/inertia';
installInertiaReload();Without it, callAction() still resolves, but the page's props stay as they were. Calling it again installs nothing more, and the function it returns removes the handler. On each success, the handler runs reloadTouched(touches):
['*']flushes Inertia's whole prefetch cache and reloads every prop;- other keys flush the prefetched pages tagged with those keys, then
router.reload({ only: touches }), so each key names a top-level prop.
reloadTouched(touches, { onFinish }) is exported for a reload of your own, and inertiaApply() is the refresh the copilot's sync runs on Inertia (the page follows the writes).
useAction()
useAction(definition, initial, { idempotencyKey }) from @agentic-actions/client/react is Inertia's useHttp for a definition, with the same data, errors, processing and Precognition validate(), plus:
run(options?), which takesuseHttp's submit options, sends anIdempotency-Key, hands the touches to theonTouched()handlers on success, and resolves the output. It resolvesundefinedwhen the server answered below 500 with anything but a success: field errors land onerrors, and anything else onrefusal. A server error or a network failure rejects. The key is made on the firstrun()and kept for retries until one succeeds.refusal: the server's sentence, ornull. A 422 on a key the form does not hold, such as a route parameter, lands here too.validate(), which also puts a 401, 403, 404, 409 or 423 onrefusal, and clears it as the next check starts.
The Inertia React recipe shows a whole form.
Versions
The npm package and the Composer package are released together under one version, and each version of the client is written for the same version of the server: keep the two equal. The generated file's first line names the Composer version that wrote it, such as from agentic-actions/laravel 0.9.0-beta.3, which is the npm version to install. After you update the Composer package, install the same version of the client (installed from vendor/, it follows on the next npm install), run php artisan actions:typescript again, and commit both. npm ls @agentic-actions/client and composer show agentic-actions/laravel print the two versions.
The client's package.json declares Node 22.3 or later. Release notes and upgrade steps for both packages are in the changelog.