Build Extensions
Write A Write Action
A write action is a MyronAction that changes state or triggers a side effect. The metadata must make that risk visible before the dispatcher runs the action and before an AI client decides whether to call it.
Use write actions for bounded extension mutations: create a vendor-owned row, update an extension setting, enqueue extension work, or delete a vendor-owned resource.
Method Choice
| Method | Use |
|---|---|
POST | Create a resource or run a non-idempotent command. |
PATCH | Mutate part of an existing resource. |
DELETE | Remove a resource. |
Do not use PUT for extension action examples. Current action conventions reserve mutations for PATCH and creation or commands for POST.
Human Path
Declare the mutation clearly in metadata:
final class MyVendorNoteCreateAction implements MyronAction
{
public function metadata(): MyronActionMetadata
{
return new MyronActionMetadata(
uri: '/api/v1/x/my_vendor/notes',
method: 'POST',
domain: 'x.my_vendor.notes',
resource: 'note',
verb: 'create',
inputSchema: [
'type' => 'object',
'properties' => [
'title' => ['type' => 'string'],
'body' => ['type' => 'string'],
],
'required' => ['title', 'body'],
],
outputSchema: [
'type' => 'object',
'properties' => [
'noteId' => ['type' => 'string'],
],
],
requiredScope: MyronApiScopes::CONTENT_WRITE,
summary: 'Create a my_vendor note.',
sideEffects: ['inserts my_vendor_notes row'],
preconditions: [
'The my_vendor extension is enabled and granted content.write.',
'title and body are non-empty strings.',
],
postconditions: ['A note row exists in my_vendor_notes.'],
audienceClass: 3,
exposeToMcp: true,
);
}
public function execute(array $input, MyronActionCallerContext $caller): MyronActionResult
{
$title = trim((string) ($input['title'] ?? ''));
$body = trim((string) ($input['body'] ?? ''));
if ($title === '' || $body === '') {
return MyronActionResult::error('precondition_failed', 'title and body must be non-empty.');
}
$noteId = 'note_' . bin2hex(random_bytes(8));
// Insert into a vendor-owned table such as my_vendor_notes.
return MyronActionResult::ok(['noteId' => $noteId]);
}
}
The dispatcher validates required schema fields and simple declared types before execute() runs. The action should still validate business preconditions that JSON Schema cannot express.
AI Path
An AI coding agent may author the action locally and run verification. It should make side effects explicit in code review notes and docs.
An installed MCP client may call the resulting tool only when all gates pass:
- extension is registered,
- extension is enabled,
- extension grant includes the manifest scope,
- action metadata
requiredScopeis held by the caller, exposeToMcpis true,- action preconditions are satisfied.
Write actions are not hidden from MCP just because they write. MyronCMS uses scopes as the primary trust gate. Use exposeToMcp:false only when the action fits a documented hidden category such as recursive trust control, Tier-3 identity/admin policy, visitor-facing behavior, internal dispatch, or an unresolved parity gap.
Scopes
Choose the narrowest current base scope that matches the mutation. Extension manifests are limited to base scopes such as content.write, design.write, and administration.write.
For the note example:
{
"requiredScopes": ["content.write"]
}
The action should also declare:
requiredScope: MyronApiScopes::CONTENT_WRITE
Artifacts
Local files:
app/extensions/my_vendor/actions/MyVendorNoteCreateAction.phpapp/extensions/my_vendor/bootstrap.phpapp/extensions/my_vendor/manifest.json- optional isolated migration for
my_vendor_notes
Runtime surfaces:
POST /api/v1/x/my_vendor/notes- MCP
tools/list - MCP
tools/call
Safety Boundary
Write actions must not mutate core tables directly. If the action needs storage, keep it inside the extension's vendor boundary, such as {vendor}_* tables created by isolated migrations.
Do not document broad-scope fixes as remediation for a failing call. If a caller lacks scope, the safe answer is to use an appropriately granted caller or have an operator review the grant. An MCP client must not grant itself trust.
Do not swallow precondition failures. Return a declared error with MyronActionResult::error() so direct API and MCP clients receive stable failure text.
Verification
Run:
php app/tests/verify-extension-system-foundation.php
Verify direct API behavior with a scoped caller:
curl -sS \
-X POST \
-H "Authorization: Bearer ${MYRON_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"title":"Release note","body":"Created by an extension action."}' \
"https://example.test/api/v1/x/my_vendor/notes"
Then verify failure behavior:
- omit
titleorbodyand confirminput_invalidorprecondition_failed, - call with a read-only token and confirm
scope_insufficient, - inspect MCP
tools/listwith a scoped and under-scoped caller.
Failure Modes
| Failure | Source | Safe fix |
|---|---|---|
input_invalid | Dispatcher schema validation. | Send all required fields with declared types. |
scope_insufficient | Dispatcher caller scope check. | Use a caller granted the action scope. |
precondition_failed | Action-declared business guard. | Satisfy the named precondition before retrying. |
business_rule_failed | Action-declared domain failure. | Fix the domain state rather than bypassing validation. |
| Tool absent from MCP | exposeToMcp:false, missing load, or missing caller scope. | Confirm exposure rationale and caller grants. |
| Uncaught exception | Action threw instead of returning a declared error. | Catch known domain failures and return MyronActionResult::error(). |