An Action defines a validated operation shared by forms and server callers. Register Actions through a plugin.
Queries, Actions, and Events use plugin.member
identifiers: the Plugin id supplies the owner, and its declaration supplies the
local member name. For example, the rename Action below is notes.rename.
Complete the local Ada account setup
in a Quickstart project. Run bun add zod, then create plugins/notes.plugin.ts:
import { defineAction, definePlugin } from "@pathmx/core"
import { z } from "zod"
export default definePlugin({
id: "notes",
actions: {
rename: defineAction({
title: "Rename",
target: "note",
input: z.object({ title: z.string().trim().min(1) }),
available: ({ can }) => can("write"),
prepare({ input: { title }, target }) {
return {
effects: { update: [target.id] },
run({ tx }) {
tx.source(target.id).data({ title })
return { title }
},
}
},
}),
},
})
This registers notes.rename. target is guaranteed to be a Source inside both
callbacks. Because this is a general editing operation, available requires the
caller's write permission. Action execution requires a current Actor admitted by a credential or local host session. Inspection Views cannot execute Actions. A Home is optional; storage features require it only when creating personal State. Actions are available
through writable live-server or browser hosts. Ordinary static export does not
provide an Action host; see browser publications.
Create paths/ada/welcome.note.md:
---
title: First note
---
# {{ source.title }}
<form
action="/actions/notes.rename"
method="post"
data-pmx-action-target="/ada/welcome.note"
>
<input type="hidden" name="_target" value="/ada/welcome.note" />
<label>Title <input name="title" required /></label>
<button type="submit">Rename</button>
</form>
Run bun run dev, sign in as Ada, and open /ada/welcome.note. Enter “Workshop
ideas” and choose Rename. The title changes; reload and inspect the file to
confirm its title: metadata was saved. An empty title is invalid. A signed-out
reader cannot read or rename this personal note; sign back in as Ada to continue.
The Home gives Ada write access, while the Action validates the particular edit.
A repository Plugin explicitly registers its Actions. To split a growing module,
move a defineAction(...) value into an imported file and keep
actions: { rename } on the owning Plugin. The action's file location does not
supply its public name or install it automatically. Portable hosts install the
same Plugin through createApp({ plugins: [notes], ... }).
Definitions register and validate before Plugin setup and Source indexing. Restart the host after changing executable Plugin or Action code; Source content continues to update through normal live sync. See the Plugin tutorial for entrypoints.
Declare target: "note" to require that Source type, or use a predicate such as
target: (source) => source.type === "note" || source.type === "draft".
The predicate may return a Promise when matching requires authored model preparation.
Omitting target declares an untargeted Action: supplying a target is then invalid,
and callback target is undefined.
Core checks the declared target and its execute permission before calling
available. The callback receives the caller's view, the current target, and
can(capability) for that target. Return:
trueto make the Action available; this is the default when omitted.falseto hide it from discovery and reject execution.- A string to describe why it is unavailable.
- An object with
status,reason,title,description,rel, orboundInputto customize its affordance. Status defaults toavailable; bound input is merged over object input before schema validation. Bound input requires an object payload.
A title makes targeted Actions discoverable through Source affordances. Actions
without a title still enforce availability. Core checks availability again against
the current target inside the prepared commit before validating and executing the
operation.
The complete form above sends the canonical Source id
in _target. It is transport metadata and is removed before validating input;
a Referer header never supplies authority or a target.
For forms authored within a Source, the Actor Path plugin supplies that containing
Source as the default _target. When explicitly targeting another Source, include
data-pmx-action-target as in the complete form so it preserves your supplied target. Custom
clients must send their target explicitly. A missing or incompatible target rejects
the request before the Action callbacks run. An admitted call that omits a required
target reports that an explicit target Source id is needed; missing or inaccessible
Sources still receive a generic rejection.
Native form fields arrive as strings or string arrays. An input schema validates
and transforms them before prepare; an optional output schema validates the
result before committing. Programmatic and JSON callers can supply structured values.
Use view.actions.run(name, input, { target }). The runner retains that exact View
through availability, preparation, transaction reads, and the receipt. target is
an optional Source id; it is required when the Action declares a target.
import type { AppContext, CredentialSession } from "@pathmx/core"
export function renameNote(
app: AppContext,
session: CredentialSession,
title: string,
) {
const view = app.view(session)
return view.actions.run(
"notes.rename",
{ title },
{
target: "/ada/welcome.note",
},
)
}
app.view(session) is a trusted host entry, available on the application and
plugin contexts. A request handler obtains its caller View with
ctx.view(await ctx.session()). The session comes from credential admission, not
caller-supplied form data. Trusted background services can explicitly admit a
separate service View for a configured service Actor.
An existing View cannot choose an Actor override. Invocation options accept
target, request and stream, which completes a Block stream. Ordinary HTTP Action forms also invoke through their admitted View.
Anonymous and inspection Views reject execution. A retained View rechecks current
Actor and target access when the operation runs.
Generated content can appear in a Source while it is being written, then be saved once. Open a stream through a View that can write the Source, write the Markdown so far, and commit it:
const stream = view.stream("/lessons/bridge.page", { after: "goals" })
for await (const text of response.partials) stream.write(text)
await stream.commit()
-
{ after: id }inserts new Blocks after a Block;{ block: id }fills one Block's body, and its text stays one Block. -
Each write replaces the Markdown so far. Everyone who can read the Source sees it in place, refreshed at most every 100 ms, without anything being saved.
-
Streamed Blocks render like saved ones and carry
data-pmx-streaming. New Blocks without anid, or with one another Block in the Source already has, get one, which they keep when saved. -
Unfinished Markdown is shown safely, for display only:
- held until complete when a partial form would render as something else:
comments and topmatter, a tag still being written, a component that has not
closed, a directive such as
[@include]or an image whose target is still being written, a table header before its delimiter row, a lone list marker or separator; - closed temporarily: fences,
$$math, code spans,**,~~, and an ordinary link, which shows its text; - deferred: an open fence drops its language, so highlighting, diagrams and fenced renderers wait for the closing fence. Code inside a fence is shown as written.
Components are custom elements, so any tag with a hyphen waits for its closing tag (or
/>) and never renders with half its props or content, unless the component shows partial content. Other HTML closes at the end of its Block. - held until complete when a partial form would render as something else:
comments and topmatter, a tag still being written, a component that has not
closed, a directive such as
-
Current write permission and contextual admission govern publication as well as saving. Losing either ends the stream and withdraws its unsaved content.
-
commit()saves the Markdown with the View's authority; the stream ends in the same update, so readers never see it twice. Further writes and duplicate commits reject while a commit is pending.cancel()discards the stream until commit preparation finishes; once persistence starts, the commit completes. -
If a replacement Block's body changed since the stream opened,
commit()ends the stream and rejects withSourceConflictError, preserving the newer saved edit. Changes to unrelated Blocks do not prevent a commit. -
A stream lives in the host process: a restart drops it, and only committed content remains.
To validate or record more than the Markdown, save through your own Action and name the stream it completes; it ends with that Action's commit:
await view.actions.run("lessons.save-section", { markdown }, { target, stream })
PathMX's HTTP connection gives each Save a hidden request identifier. An interrupted response or a busy/restarting host is retried with the same identifier and payload, up to four attempts. If those attempts cannot confirm the result, the control reports an uncertain outcome; retrying the same Save through that connection retains its identifier. Built-in controls preserve the failed form and offer Reload after those attempts are exhausted. A confirmed Save ends that invocation; another Save gets a new identifier. The identifier requires no authored form field or Plugin change.
Server callers can retain an identifier explicitly:
const request = crypto.randomUUID()
const rename = () => view.actions.run(
"notes.rename",
{ title: "Workshop ideas" },
{ target: "/ada/welcome.note", request },
)
const receipt = await rename()
const confirmed = await rename() // The original receipt; no second write or job.
view.actions.submit(name, fields, { request }) provides the same behavior for
form fields. Direct HTTP callers send the identifier as Idempotency-Key, alongside
their usual same-origin credentials and Accept: application/json. Identifiers
contain 1–128 letters, digits, dots, underscores, colons or hyphens.
The durable host stores the original receipt atomically with the committed marker and Event admission. Completed operations retain a compact summary instead of full before/after Source snapshots; unresolved saves retain their recovery data. Retrying under the same admitted identity returns that receipt, including after a restart, without repeating the Action or admitting its jobs again. Current Actor, target and Action availability checks still apply. Reusing an identifier with a different Action, target, context or payload is rejected; changed work needs a new identifier. Request-backed input and output must be JSON values; a void output is also supported. Bun and Cloudflare use this shared behavior. A Bun site without Plugin jobs opens its durable journal on the first request-backed Save; an existing journal is opened and recovered at startup.
Keep the identifier until the outcome is known. Closing the connection discards its automatic retry memory; callers that need recovery across a closed connection retain an explicit identifier. Native browser form POSTs without PathMX's client connection do not add an identifier automatically.
Action input can be an object, array, scalar, or any other value accepted by its
Zod schema. view.actions.run passes domain values directly. HTTP JSON callers
POST an envelope to /actions/<name> with Content-Type: application/json:
{
"target": "/lessons/contrast.quiz",
"input": {
"submission": "attempt-1",
"responses": {
"assessment": {
"choice": "contrast",
"explanation": "The text is easier to read."
}
}
}
}
This example invokes /actions/input.submit. target is transport metadata;
input is the complete domain payload. Request JSON may contain only these two
keys. Browser requests require same-origin credential admission; send
Accept: application/json for the Action receipt.
An Action's input can also be a schema resolver returning a schema or a Promise
of a schema. It receives the
same { target, view, app, can } context as availability, after target admission:
import { defineAction } from "@pathmx/core"
import { z } from "zod"
export const choose = defineAction({
title: "Choose",
target: "question",
input: ({ target }) =>
z.strictObject({
choice: z.literal(String(target.data.choice)),
}),
prepare({ input }) {
return { run: () => input }
},
})
Source discovery publishes the resolved input JSON Schema. Execution resolves it again against the current Source, so stale discovered choices cannot bypass current validation. Static schemas remain useful for operations whose shape is fixed.
Input derives its schema from each Source's authored responses. Call
view.actions.run("input.submit", { responses: { [response]: values }, submission }, { target }).
Each responses key identifies an authored response Block; optional submission identifies an
attempt for contributions such as Assessment to recognize retries. values uses
domain field names, with text strings, numeric answers, and arrays for multiple
choices or sequences. Unknown fields fail validation. Conditional responses require
and save only active fields; their schema describes this runtime rule.
Generated Input forms use /actions/input.submit. Input's Action form decoder converts native
controls and JSON-encoded _input.* custom fields into this same domain payload,
then invokes input.submit. Forms and programmatic callers share validation,
State writes, Assessment grading, and contribution replay checks.
view.actions.submit(name, fields) accepts ordered [name, value] text pairs.
It consumes _target as the target Source id and rejects repeated or empty targets.
Repeated domain fields remain arrays. Binary fields are rejected before invocation.
An Action may define form({ fields, view, app, target, can }) to convert native strings and arrays
into its structured input. The decoder runs within serialized preparation after
target access and availability checks. Its result then receives bound input and
normal schema validation before prepare runs. Without a decoder, the text-field
record is the input. Programmatic actions.run already receives structured input
and does not invoke the decoder.
const recordQuantity = defineAction({
target: "order",
form: ({ fields }) => ({ quantity: Number(fields.quantity) }),
input: z.strictObject({ quantity: z.number().int().positive() }),
prepare: ({ input }) => ({ run: () => input.quantity }),
})
Generated Input forms name input.submit in their compiled metadata and use the
generic /actions/input.submit endpoint. HTTP performs origin and credential checks
before calling the same View runner used by an in-process or worker host.
Browser Action reconciliation is automatic. If live delivery has already applied the committed state, Core can reuse that read; otherwise it refreshes the current View. Action authors do not manage freshness versions or opt into this behavior.
JSON Action failures include an outcome field in their Problem Details body:
rejected, partial, or uncertain. An HTTP status alone does not establish
whether persistence occurred. Schema validation failures also include issues, an
array of { path, message } entries. View connections retain these on the rejected
Action failure, allowing a form to identify its failing field without parsing the
message. Core includes the validation location and message without adding the rejected input
value to the entry.
A successful receipt remains successful if later
projection or page refresh fails; retry the read, not the Action. See
View connections.
prepare({ input, view, app, principal, operation, target }) receives validated
input and one named context. Resolve optional Homes through
app.access.home(actorId) on the supplied Plugin-scoped app; domain plugins
interpret their own roles. Derive ids and perform
read-only preparation here. It has no transaction or Event recorder and must not
perform external side effects. Return { effects, run }; run({ tx, events })
stages the operation using the values captured during preparation.
Effects are finite lists of canonical Source ids, without .md:
readgrants additional service reads beyond the caller's View.create,update, anddeletegrant exactly those respective changes.- Omitting a list grants nothing for that operation. Omitting
effectspermits ordinary View reads but no Source writes.
An admitted domain Action can write its declared Sources even when the caller has no general write permission. That lets a submission service save a private attempt, or a badge Action update only the participant's name. The trusted Action must derive its effects from its domain rules; copying arbitrary caller-supplied ids into effects would authorize those ids. General editors should require the caller's write permission, as in the example above.
A write grant does not grant a hidden read. Updating through tx.source(id) or
deleting through tx.delete(id) also needs read: [id] when the View cannot read
that Source: both operations read existing content before applying the change.
When an admitted update/delete effect is missing this read declaration, the Action
reports action-read-undeclared and asks you to add the target to effects.read.
Contextual policy denials remain generic; declaring a read effect cannot override
those restrictions. Newly created content can be read within its transaction.
Create effects cannot overwrite an existing Source, even when that Source is hidden.
Declare and create the same canonical typed id, such as
/ada/approvals/first.mentor-approval. Use that exact id in effects.create and
tx.create(id, content). An untyped Markdown filename normally has a .page id;
adding type frontmatter does not repair a mismatched create declaration.
Check the typed id and all required effects when a create is denied; visitor
errors intentionally do not disclose hidden Sources.
Core snapshots the effect lists before run, checks staged changes, and retains
Source grant and attachment invariants. A contextual View
further restricts named invocation and each declared service effect. Effects are private execution details and
are not exposed in affordances.
Effects also preserve Source authority. They cannot create or promote an Actor,
change a Source's path-derived identity through type frontmatter, alter Auth User
email/role policy, or create/remove/retarget a Home claim. Use an explicit trusted
repository-management operation for those authoring changes. A first Block body
replacement that begins like YAML frontmatter is rejected when it could become
Source metadata.
tx.source(id).data(patch) replaces only the supplied top-level values. For example:
tx.source(target.id).data({ cover: "./cover.png", art: { style: "ink" } })
This changes cover and replaces the entire art value; it does not deeply merge
nested maps. Untouched YAML entries retain their formatting, comments and order,
and the Markdown body stays byte-for-byte unchanged. New keys are appended.
A supplied undefined or empty string removes that key; null is a stored value.
An empty patch does nothing. Comments inside a replaced nested value belong to
that replacement. An edit that would leave invalid YAML, such as deleting an
anchor still used by another field, fails before commit.
tx.source(id) also edits Blocks: appendBlock, prependBlock, updateBlock(id, …),
removeBlock(id) and moveBlock(id, { before }) or moveBlock(id, { after }).
Block edits preserve Source metadata, author comments and the Markdown meaning of
unaffected content. Core reuses existing sections where possible and serializes
changed YAML through the shared YAML utilities. Ordinary formatting may normalize;
exact quoting, spacing and line endings are not a general mutation guarantee.
Updating only a Block body retains its metadata comments, and changing only its
metadata retains the body. Comments inside a replaced metadata value belong to
that replacement.
A Block with an authored id (<!-- id: plan -->) keeps it wherever it moves. A Block
without one is addressed by its position (block-2), so moving Blocks changes which
Block a positional id names; give a Block an authored id when code needs to find it
again after reordering.
tx.source(target.id).moveBlock("demos", { before: "plan" })
Preparation, execution, output validation, and staging occur inside one prepared commit. Source changes and recorded Events publish only after it succeeds. A failed read, undeclared write, invalid result, or invalid Event aborts the staged changes. This bounds the transaction offered to trusted plugin code; it is not a sandbox for installed JavaScript.
Start with Assessment's complete setup and question Source,
including dependencies, Actor/Home admission, and a submission result. The stock
Assessment package preset connects to the existing input.submit Action without
a repository Plugin. Its equivalent composition is:
import { definePreset } from "@pathmx/core"
import { createInputPlugin } from "@pathmx/core/plugins"
import { createAssessmentPlugin } from "@pathmx/assessment"
const assessment = createAssessmentPlugin()
const input = createInputPlugin({
authoring: [assessment.input],
submissions: [assessment.submission],
})
export default definePreset([input.plugin, assessment.plugin])
A custom InputSubmissionContribution defines prepare(submission, context) and
returns a plan with effects, optional replayed({ tx }), and
run({ tx, events }). Return undefined when the contribution does not apply.
The replay transaction exposes only read, readSource, and exists; it has no
write methods or Event recorder. The submission includes the origin Source, planned
Input State id, retry identity (revision), and a responses map from Block ids
to validated field values. Single-Block and multi-Block saves use the same map.
Input combines the plans' effects with its own State write. It runs read-only replay checks before staging any writes or Events; a matching replay returns the existing submission identity. Otherwise it stages Input State and every contribution in the same transaction. Assessment uses this to preserve immutable attempts, update its latest-result pointer, and avoid rerunning grading on a replay. A replay checker must reject reuse of an identity with different answers.
For an Action invoked by a durable job, use
step.action. It saves the receipt
with the Action commit and reuses it on replay without repeating Source changes
or Event admission. Ordinary view.actions.run remains a new invocation per call
unless you retain an explicit request identifier.
When a durable host cannot confirm an operation's result, its Action failure can
include an operation id. Preserve that id and inspect it with
bun run pmx jobs operation '<operation-id>'. Recovery checks the saved Source
intent before normal access resumes; see durable jobs.