Skip to content

The pipeline ​

A form, an agent, an MCP client, a queued job or your own code: every call to an action takes the same steps, in the same order.

  1. the door#[Expose], the surface's switch, the agent's toolset
    not found: 404
  2. the tokenthe effect's ability, such as actions:write
    not found: 404
  3. shouldRegister()exposure, not authorization
    not found: 404
  4. tenant membershipwhen the call has a tenant
    not a member: 404
  5. authorize()when it takes no input
    denied: 403
  6. the arguments cutagents and MCP: only the advertised schema
  7. input preparedfixed input, conversions, prepareForValidation()
  8. validationschema() plus rules()
    invalid: 422
  9. authorize()when it takes ValidatedInput
    denied: 403
  10. the person confirmsan agent's Destructive or External call
    declined: nothing runs
  11. handle()then the output projection and modelReply()
    Refusal: 409 by default
  12. one eventwith no input valuesActionCompletedActionRefusedActionFailed

Dashed: only some callers take this step.

A step that says no ends the call with the answer beside it.

Where a call stops ​

The door, the token, shouldRegister() and membership answer "not found" when they say no, so an action a caller may not see reads exactly like one that does not exist. A denied authorize() can answer 404 too, when it returns Response::denyAsNotFound(). See The pipeline in Concepts.

What an agent's tool list shows ​

Before each turn, the agent's tools are built by running every check up to an authorize() without input, for each action in its toolsets. An action leaves the list when one of them says no, or when it would offer a forbidden key.

Before each turn, per action

  1. the door
  2. the token
  3. shouldRegister()
  4. tenant membership
  5. authorize() without input
The agent's tools this turn
create-postoffered
list-postsoffered
update-postauthorize() takes input: it runs at the calloffered
publish-postauthorize() said noleft out
import-postsshouldRegister() said noleft out
An authorize() that takes input needs the model's arguments, so its tool stays listed until the model calls it.

Checks on the caller, such as a role, go in an authorize() without input, which keeps the tool off the list; checks on a particular row go in handle() or in an authorize() that takes ValidatedInput.

app/Actions/UpdatePost.php
php
public function authorize(ActionContext $context): bool
{
    return $context->actor(User::class)->can('edit-posts');
}

When authorize() has to take input, put the check on the caller in shouldRegister(), as the strict agent schemas recipe does. See What an agent's tool list shows in Concepts.

Refusals ​

An action says no from its own code with a Refusal. on() puts the message on a field, which answers 422 like a validation error; otherwise status() sets the HTTP status, 409 by default. Actions::refuse() turns an exception your domain already throws into a refusal on every surface.

app/Actions/CreatePost.php
php
throw Refusal::make(__('You already have a post with that title.'))->on('title');

Replacements in the message come from your own text or the person's saved rows, never from the caller's input. See Refusals in Concepts for details() and listing().

Released under the MIT License.