Plugin API

Use this reference alongside Make a custom Plugin. Import the helpers and public types below from @pathmx/core. Callback types are inferred by the helpers; no internal module imports are needed.

Definitions and registration

APIContract
definePlugin(definition)Returns an ordinary Plugin, preserving its inferred type. Only id is required.
definePlugin({ config, create })Creates a schema-validated configurable factory. create receives the schema's parsed output and returns a Plugin. See configuration.
definePreset([...])Composes Plugins, factories, and nested presets in order. Each Plugin retains its identity and resources.
htmlTagged template that escapes ordinary interpolated values and composes HTML fragments and arrays. null, undefined, and booleans produce no text.

Plugin ids use lowercase letters, numbers, and single hyphens between segments. defineComponent({ tag, props, compile, load, render, client, css }) declares an authored component such as x-reading-time. Add definitions to a Plugin's components array or default-export a standalone component. moduleRef(() => import("./render.ts")) links a default-exported renderer or client without mixing its portable code with host dependencies.

Props schemas own defaults, validation and transformations. Core decodes literal attributes according to the schema; whole bindings retain their JSON types. A z.number() prop accepts count="4". A z.boolean() prop follows HTML: open, open="" and open="true" are true, open="false" is false, and an omitted attribute uses the schema default. Invalid values carry Source and Block diagnostics. Component tags identify their definitions independently of the containing Plugin id.

HTML escaping does not validate URL schemes; validate authored URLs before putting them in links.

The Bun host discovers these entrypoints beside the paths/ directory:

plugins/*.plugin.{ts,tsx,js,jsx}
plugins/*/index.plugin.{ts,tsx,js,jsx}

Default-export a Plugin, a synchronous zero-argument PluginFactory, or a PluginPreset, or standalone component. Discovery is ordered by filename. requires contains Plugin ids that must already be registered; it does not fetch or install dependencies. Use factories for app-owned mutable resources. Package activation and replacement rules are described in Packages. The portable createApp() host receives its Plugins explicitly and does not scan directories; see Core API.

Contributions

These optional fields extend an ordinary Plugin definition.

FieldValue and behavior
requiresReadonly array of earlier-installed Plugin ids.
componentsComponent definitions with validated props, optional compilation and loading, portable rendering and optional clients.
compiledBlock-level compilation, admission and evaluation for Block outputs such as Input responses. Authored components use components.
displaysNamed Block presentations with optional authored-data validate; each is rendered by the Plugin's x-{plugin-id}-group-{display-name} component. See Block displays.
schemaA SchemaContribution: Source/Block types, data properties, and settings validation. Use exported defineSchema for schema definitions.
actionsRecord of local names to Actions; registered as plugin.name. See Actions.
queriesRecord of local query names to query contributions; registered as plugin.name. Resolve viewer-safe reads through the query context's view. See Queries.
bindingsRecord of unique application-wide named View reads, reserved against local shadowing. See repository-wide bindings.
eventsRecord of local names to exported defineEvent definitions; registered as plugin.name. Server and Action Events are available through ctx.events.
jobsRecord of local names to defineJob contributions for trusted Events. See durable jobs.
relationsReadonly array of RelationContribution definitions for durable relations and optional rewrite behavior.
impliedInstances({ source, iid }) => hrefs: Source instances that one declared instance implies, as iid-only hrefs relative to source. Core declares them like authored links, transitively. A Path instance can instantiate its Steps this way.
databaseOptional isolated SQL database with append-only migrations; see below.
routesArray of relative HTTP routes created with route; see below.
credentialsA CredentialHost for request admission; see authentication.

Prefer the linked feature references for complete domain examples. Registration names such as reading.reset are distinct from authored tags such as x-reading-time and HTTP mounts such as /api/reading/.

Schemas, queries, and bindings

A Source type may set routes: false to keep its Sources out of public routes, fragments, and search while retaining trusted server and CLI access. Email uses this for authored message templates.

defineSchema({ sources?, blocks?, props?, settings? }) preserves its inferred shape. Source and Block entries are keyed by type and accept label, description, icon, color, and a Zod schema for the complete authored data map. props assigns individual data property schemas with exclusive ownership. settings.personal validates personal preferences through shared State.

Core's Meta plugin owns the overridable icon prop: a supported built-in icon name or a single emoji. Source Views and query summaries expose meta.icon, using the effective authored value or the registered Source-type icon. Custom consumers should use this resolved value instead of implementing their own fallback.

A data prop can opt into named Source-instance overrides:

props: {
  start: { schema: z.string(), overridable: true },
  options: {
    schema: z.strictObject({ label: z.string(), count: z.coerce.number().int() }),
    overridable: true,
  },
}

A plain schema entry remains non-overridable. Constructor values arrive as strings; the owning schema controls validation and explicit coercion. An authored link such as [Morning](./break.event.md?iid=morning&start=10:30) declares an instance. For object props, dotted fields such as ?iid=retry&options.label=Retry&options.count=3 replace those fields while retaining other Source defaults. Core validates the resulting object with the prop's schema. URL leaves remain strings, so the schema must explicitly allow any number or boolean conversion. Dotted paths traverse objects, not arrays; a whole-prop argument cannot overlap its dotted fields. The root prop must opt into overrides. Conflicting declarations fail indexing and are reported by lint.

Read the admitted instance with view.readSource("/break.event", { iid: "morning" }). The result preserves its canonical id, adds iid, and exposes effective data, metadata, and instance-aware Source/Block hrefs. An authored iid-only link uses Source defaults when no override declaration exists. Missing or inaccessible instances return undefined; omitting the options reads the default Source. Instances supplied by impliedInstances retain the originating declaration's access requirements through every implied parent. Public templates do not expose instances declared only in private Sources. An independent admitted declaration can make the same instance available.

Use sourceReference(source) when passing a resolved Source or Source View to an Action or saving a State target. It preserves iid, producing a reference such as /assignment.project?iid=retry. parseSourceReference(reference) returns the canonical { id, iid? } pair. Constructor values belong in authored declarations, not in Action targets or State for values.

await view.actions.run("input.submit", {
  responses: { source: { answer: "My revised answer" } },
}, { target: "/assignment.project?iid=retry" })

HTML Action forms use the same reference in _target. Actor State stores it in for; Input responses and Actor Completion remain separate per instance and Actor. Source-backed Completion edits authored Markdown and does not offer mutation for named instances. Use Actor storage for independently completable attempts.

Static export pre-renders publicly readable named instances and their admitted configuration. The static browser runtime selects the representation while keeping the authored ?iid=... URL; direct instance navigation requires JavaScript. Included instance content is already rendered in the exported page. Private configuration is not exported, and public links to unavailable instances fail export validation. Instance support does not add writable Actor storage to an ordinary static site.

This complete Plugin contributes a type, a query, and a named binding. Save it as plugins/reading-list.plugin.ts:

import { definePlugin, defineSchema } from "@pathmx/core"
import { z } from "zod"

export default definePlugin({
  id: "reading-list",
  schema: defineSchema({
    sources: {
      article: { label: "Article", icon: "file-text" },
    },
  }),
  queries: {
    titles: {
      output: z.array(z.object({ id: z.string(), title: z.string() })),
      defaultSort: [{ by: "title" }],
      async resolve(context) {
        const sources = await context.query("sources", {
          from: context.from,
          type: "article",
        })
        return sources.map((source) => ({
          id: source.id,
          title: source.meta.title,
        }))
      },
    },
  },
  bindings: {
    readingArticles: { query: "sources", from: "/", type: "article" },
  },
})

Add paths/first.article.md with # First article, then run bun run pmx query reading-list.titles --from /. Its row contains that Source's id and title. The same query can be used in authored query fences, a View, and POST /api/query on a live server. A registered query is a viewer-safe read capability on all those surfaces, so keep work bounded for arbitrary valid input.

A query definition has optional Zod input, required Zod array output, optional defaultSort, and resolve({ input, view, app, from, query, depend }). The resolver context exposes validated input, view, owning app, normalized from, query, and depend. Compose admitted reads through context.query or context.view; do not substitute unrestricted repository data. Query requests accept from, input, where, sort, and limit.

A binding is { source: id } or a query descriptor with query and optional input, from, type, has, recursive, links, where, and sort. Binding descriptors do not accept limit. Binding names are application-wide; see binding consumption.

Schedule reader

Core's default stack includes Schedule. A custom presentation can read effective Source or Block schedules through the current View:

import { createSchedulePlugin } from "@pathmx/core/schedule"

const schedule = createSchedulePlugin()
const item = schedule.read(view, "/workshop.event?iid=afternoon")
const markup = item ? schedule.render(item, { includeDate: true }) : ""

The reader returns resolved start/end/due instants with their authored values and timezone. It retains the selected Source instance and respects View admission. render produces exact time markup by default; use authored Schedule components for live relative time, event status and elapsed progress. See Schedule authoring.

Location reader

Core's default plugin stack includes Location. Custom presentations can use its reader without registering another copy:

import { createLocationPlugin } from "@pathmx/core/location"

const location = createLocationPlugin()
const details = location.read(view, "/workshop.event?iid=afternoon")
// details?.place: { label, href }
// details?.online: { label: "Join online", href }
const markup = location.render(details)

read accepts a canonical Source reference, including an optional instance id. It returns the admitted physical and online entries, or undefined if neither is available. render returns escaped link markup. <x-location-details /> uses the same reader for its owning Source or included instance. Apps assembling a custom plugin stack can register location.plugin explicitly. See Location authoring.

Actions and committed events

Define Actions with defineAction, then put them in the Plugin's actions record. An Action accepts title, description, target, input, form, output, available, and required prepare. A string target matches a Source type; a predicate can select Sources explicitly. Without target, the Action is untargeted. Input may be a Zod schema or a function of the current View/target; output is an optional Zod schema.

prepare({ input, view, app, principal, operation, target }) returns { effects?, run }. Preparation is read-only. Effects declare finite read, create, update, and delete Source ids. run({ tx, events }) stages changes and returns the result. available({ view, target, can }) controls availability; actual authority must also be enforced during preparation. Follow the complete Action example and effect rules.

To emit a domain event, define it once and register that same definition in events. This complete Plugin records a validated acknowledgement from an admitted Action caller:

import { defineAction, defineEvent, definePlugin } from "@pathmx/core"
import { z } from "zod"

const Acknowledged = defineEvent({ data: z.object({ message: z.string() }) })

export default function AcknowledgementsPlugin() {
  let unsubscribe: (() => void) | undefined
  return definePlugin({
    id: "acknowledgements",
    events: { acknowledged: Acknowledged },
    actions: {
      acknowledge: defineAction({
        input: z.object({ message: z.string().min(1).max(200) }),
        output: z.object({ recorded: z.boolean() }),
        prepare({ input }) {
          return {
            run({ events }) {
              events.record(Acknowledged, input)
              return { recorded: true }
            },
          }
        },
      }),
    },
    setup(ctx) {
      unsubscribe = ctx.events.subscribe((records) => {
        for (const { event } of records) {
          if (event.type === "acknowledgements.acknowledged") {
            ctx.log.info("acknowledged", { id: event.id })
          }
        }
      })
    },
    close() {
      unsubscribe?.()
    },
  })
}

Save as plugins/acknowledgements.plugin.ts. Through an admitted View, run await view.actions.run("acknowledgements.acknowledge", { message: "Ready" }). The receipt's output is { recorded: true }, and the subscriber logs its event id after commit. This example requires an admitted caller; it is not an anonymous HTTP endpoint.

events.record(definition, data) stages schema-validated event data. Failed staging or validation publishes neither Source changes nor staged events. Subscribers receive committed EventRecord[] in Source commit order. Each record has event, cursor, recordedAt, and optional tracking metadata. Each Event has id, type, occurredAt, origin, and data. An Action origin contains { kind: "action", operation, actor, target? }; server origins contain { kind: "server", plugin }. Core also emits action.completed for successful invocations, including no-ops.

ctx.events.emit(definition, data) publishes a validated Event owned by that Plugin. definitions() exposes registered metadata; records(after?) reads the bounded in-memory journal. Expired or unknown cursors throw. Subscribers own their pending work and are not awaited by Core. See Events for tracking declarations, browser observations, limits, and complete producer examples.

Durable and contextual relations

Use relations when a Plugin introduces links outside ordinary Markdown syntax. Each contribution implements synchronous read(source), returning { key, rel, href, target?, label?, qualifiers? }[]. Keep key stable within that Source and contribution when only the destination changes.

Optional rewrite(source, changes, ctx) returns the complete revised Source text. Changes are { type: "retarget", key, href } or { type: "remove", key }. For frontmatter-owned links, ctx.patchFrontmatter(patch) produces complete Source text without hand-rebuilding the header. Implement rewrites when those links should participate in Source move/remove operations.

For example, a contribution can expose a related frontmatter link:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "related",
  relations: [
    {
      read(source) {
        return typeof source.data.related === "string"
          ? [{ key: "related", rel: "related", href: source.data.related }]
          : []
      },
      rewrite(source, changes, ctx) {
        const change = changes.find((change) => change.key === "related")
        if (!change) return source.content
        return ctx.patchFrontmatter({
          related: change.type === "retarget" ? change.href : null,
        })
      },
    },
  ],
})

Save as plugins/related.plugin.ts. Author related: ./other.page.md in a Source's frontmatter and create that target. Preview a move with bun run pmx mv other.page.md renamed.page.md --dry-run --format json; the preview should include the frontmatter retargeting. A removal sets the field to null, which the reader ignores.

Directory references use target: "scope". They follow a folder move without becoming links to an index Source at that route. Moving or deleting only the index Source leaves the scope unchanged. Core's query Plugin uses this for explicit query from: values.

For example, plugins/catalog.plugin.ts can own a catalog directory field:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "catalog",
  relations: [{
    read(source) {
      return typeof source.data.catalog === "string"
        ? [{ key: "catalog", rel: "catalog", href: source.data.catalog, target: "scope" }]
        : []
    },
    rewrite(source, changes, ctx) {
      const change = changes.find((change) => change.key === "catalog")
      if (!change) return source.content
      if (change.type === "remove") throw new Error("A catalog requires a scope.")
      return ctx.patchFrontmatter({ catalog: change.href })
    },
  }],
})

Author catalog: /resources/codex in a Source and create paths/resources/codex/entry.person.md. Running bun run pmx mv resources/codex codex --dry-run previews catalog: /codex. The graph reports these references with type: "scope" and no Source target. An affected contribution without rewrite blocks the complete move before any files change. References inside a moved tree that retain the same href need no rewrite callback.

contextualRelations(source, { view, location? }, app) adds relations for an explicit representation without writing durable graph links. Return SourceRelation[]: rel, qualifiers, type, href, optional label, fragment, and target: { type: "source", id }. Use the admitted View to check any linked content; contextual links must not expose hidden Source data.

Components

import { defineComponent, html } from "@pathmx/core"
import { z } from "zod"

export default defineComponent({
  tag: "x-reading-time",
  props: z.object({ words: z.number().int().positive() }),
  render({ props }) {
    const minutes = Math.max(1, Math.ceil(props.words / 200))
    return html`<span>${minutes} min read</span>`
  },
})

compile({ props, body, source, block, readSource, depend, invalid, app }) performs static preparation only for invocations of this component’s declared tag. staticProps: ["name"] selects the props it can use. Those props must be literal; bindings produce a diagnostic. body is authored content, not rendered slots. Return portable prepared data. readSource resolves relative references and tracks dependencies; depend records other compiler inputs.

load({ props, compiled, source, page, block, view, app, query, session, location, depend, signal }) reads current authorized data. It runs with the complete validated props and prepared result. source identifies the authoring Source and page the displayed Source, including its instance. Use view and query for access-controlled reads. app supplies the owning Plugin's host services. Return selected JSON data, not host handles or session objects. Omit optional properties instead of returning undefined; invalid JSON data reports the component, phase and field path (for example, $["items"][1]["cover"]). Selected data is deeply frozen and object keys are canonicalized in sorted order; do not use that ordering to preserve authored files. Use arrays such as [{ id, label }] whenever authored order matters, including question choices and navigation. Use null for an unavailable result and signal for cancellable work. block is the containing Block id. For per-Block presentation, read its metadata through the admitted Source and pass selected data to render:

load({ source, block, view }) {
  const role = view.readSource(source.id)?.blocks.find(item => item.id === block)?.data.role
  return typeof role === "string" ? role : null
}

This lets a role Component use its Block's role without repeating it as an attribute. compile also receives the authored Block for static preparation.

render({ props, compiled, data, slots }) returns markup composed with html. Slot strings are already rendered HTML; use fragment when including them. Render inline when the whole module is portable, or use render: moduleRef(() => import("./card.render.ts")) with a default-exported function. A separate renderer declares the props and data types it reads, like any function's parameters. defineComponent checks them against props and load; TypeScript reports a mismatch on the props or load property.

A whole binding such as rows="{{ $results.rows }}" preserves arrays, objects, booleans, numbers and null through native and literate calls. Mixed text such as label="Score: {{ $scores.total }}" produces a string. Keep structured props typed; do not serialize them into strings before passing them to a component.

css accepts imported CSS text and is included only when the component is used. Identical styles are deduplicated. A stylesheet does not require a JavaScript client.

client: moduleRef(() => import("./card.client.ts")) selects a client module. Its default export receives { element, data, signal, field } and can return { update(data), dispose() }. To select client data, render { html, clientData }. Core serializes that JSON value and preserves the client-owned root. Use the signal for event listeners and dispose resources when the component leaves. See React for a complete client example.

The client root is the first element of your rendered HTML, not the authored x- tag, which does not remain in the page. Render one root element and give it a class for your styles and queries; x-card { … } matches nothing. That root is the client's element. update(data) runs after a live or Action render changes that root, such as new selected data, including for a component placed in a Layout.

input: true attaches a custom editor to the existing Input field named by its required string name prop. Core validates ownership in the same Block. The client receives a handle for that one field instead of declaring another response or draft store. For one editor that edits several fields, explicitly list the props naming them:

props: z.object({ name: z.string(), mark: z.string() }),
input: ["name", "mark"],

The array contains prop keys; their validated string values identify existing fields in the same response Block. It must contain at least one required string prop. Core rejects missing fields and cross-Block bindings. A label or other prop never becomes a binding automatically.

Multi-field clients obtain named handles with inputFor(element).field(name) from @pathmx/core/input/browser; context.field is supplied only for input: true. Pass the field names in clientData when the client needs them. Subscriptions, drafts and Submit use the same Input response owner. Removing or rebinding the component revokes its handles. Undeclared fields remain inaccessible even if they share the form. See the multi-field example.

While a Block stream writes a component, readers see nothing of it until its closing tag arrives, so it never renders with half its props or content. A component whose content reads well while partial, such as a callout of prose, can show it as it arrives:

export const callout = defineComponent({
  tag: "x-callout",
  streaming: "partial",
  render: ({ slots }) => html`<aside class="callout">${fragment(slots.default ?? "")}</aside>`,
})

Once the opening tag is complete, the component renders with its tag closed for display; a half-written attribute still waits. What it contains follows the same streaming rules, so a component inside it that has not opted in still waits. Its load runs on every refresh while the stream writes, so leave streaming at its default, "complete", for components with expensive reads. Style the streaming state with [data-pmx-streaming] .callout; the saved Block renders normally.

Block outputs

Block-producing contributions own domain outputs independently of presentation. Their compiler declares outputs; the runtime supplies the values. Other Blocks read them as $block.field. Core resolves dependencies, rejects ambiguous fields and cycles, and applies when: participation. Rendering a report or control must not become a prerequisite for those outputs to exist.

Lifecycle and rendering hooks

Except where stated, hooks may return their result directly or as a Promise. app is the request-independent AppContext described below.

HookResult and purpose
setup(ctx)void; initialize owned resources after registration, before indexing. Receives PluginContext.
close(ctx)void; release owned resources during app shutdown. Hosts must await app.close().
sourceRoutes(source)Synchronous, pure; return route declarations owned by the indexed Source. Core replaces the claims after each complete Source change.
source({ before?, after? }, app)Synchronous; replace Plugin-owned indexed state on a Source transition. Return nothing or invalidation information. Addition has after, removal has before, and edits have both.
precompile(markdown, ctx, app)Return Markdown before HTML rendering.
postcompile(html, ctx, app)Return HTML after rendering a Source or Block.
page(html, ctx, app)Return assembled page HTML; collect page-specific assets through ctx.
documentAssets(ctx, app)void; collect document assets with optional ctx.page and ctx.view. Visitor selection belongs to this response.
streamSelectors(ctx, app)Synchronous; return a selector-to-handler record, with element(el) handlers for final HTML. ctx is the viewer context.
lint(report, app)void; report Source-local diagnostics only when lint explicitly runs.
publish(ctx, app)void; register finite public GET paths with ctx.route(pathname) or reject an unsupported authored use with ctx.unsupported(source, message).
commands(group, ctx)Synchronous registration of administrator CLI commands; command actions may be async.

The precompile and postcompile context has source, optional block, scope (page or embedded), stable containing instance, insertHtml, elements, and depend. The page hook receives the page context described above. Document assets support ctx.addAsset(...), for example { type: "stylesheet", href: "/theme.css" }, and await ctx.addBrowserBundle(new URL("./client.ts", import.meta.url), "tools"). Use a component-owned client when code is needed only for that component.

Declare browser modules used by conditional hooks in browserModules so hosts that build before serving can prepare every possible response:

const tools = new URL("./tools.client.ts", import.meta.url)
export default definePlugin({
  id: "tools",
  browserModules: [tools],
  async documentAssets(ctx) {
    if (ctx.view?.identity.type === "authenticated") {
      await ctx.addBrowserBundle(tools, "tools")
    }
  },
})

The declaration does not add the module to a page. The component or hook still owns response inclusion. Component client entrypoints are declared automatically.

A source result can include dependencies, sourceIds, and targets. Dependencies identify stale compiled work; targets request live reconciliation without implying a rebuild. A target is { type: "route", route, actors? } or { type: "actor", actors }. Dependencies use { type: "source", path }, { type: "block", path, id }, { type: "plugin", plugin, key }, or { type: "bundle", entrypoint }.

Declare the smallest dependency the consumer reads. For example, ctx.depend({ type: "block", path: "/shared.md", id: "hint" }) tracks the hint Block without also depending on every sibling in /shared.md. An unrelated sibling-body edit preserves that consumer's compiled artifact. Editing or removing hint invalidates it. Source metadata, shared definitions, or structural changes that affect the Block's context can invalidate it as well. Use a Source dependency when the consumer actually reads the whole Source.

For same-page live and action refreshes, Core can transmit only changed rendered Block roots when the document shell and Block order remain unchanged. This is independent of dependency declarations: the server still renders with current access checks and Plugin hooks before comparing output. Layout, assets, Block structure, or an unavailable baseline fall back to the full HTML response. Browser-owned state in unchanged Blocks is preserved. This does not make custom Plugin output eligible for navigation caching.

For lint, call report(source, message, details?); details can include severity (error by default or warning), code, hint, block, line, and column. For an expected compilation failure, app.invalid(source, message, details?) throws a collectable diagnostic; its details omit severity. Supply a stable Plugin-specific code, the owning block when known, and a concrete repair hint. Both text and JSON lint output retain these details. Reserve this path for expected authoring problems; let unexpected implementation errors propagate. See the lint example.

Insert rendered HTML

Use ctx.insertHtml(renderedHtml) to place fully rendered block HTML into a transform's output. Pass { inline: true } for phrasing HTML inside a paragraph. The returned string belongs to the current compilation and can be returned from precompile or postcompile, or composed into their output. The compiler preserves the supplied HTML until all Markdown hooks finish, then restores it before page hooks and View rendering. Those later phases still see the inserted content.

For example, save this as plugins/notice.plugin.ts:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "notice",
  precompile(markdown, ctx) {
    if (markdown.trim() !== ":::notice") return markdown
    return ctx.insertHtml("<aside>A generated notice.</aside>")
  },
})

A Block containing only :::notice renders the aside. To preserve inline placement, ctx.insertHtml("<kbd>Enter</kbd>", { inline: true }) can be composed into running prose. Pass rendered HTML: insertion does not parse Markdown or escape the supplied string. Compose other Sources through authored includes and source-backed components so Core retains their identity, admission, dependencies, and instance boundaries. app.compile.models(source) exposes the calling Plugin's current compiled Block models when host operations need declarations.

Contexts and access

ContextAvailable capabilities
PluginContextplugin, log, runtime, optional database, and Plugin-bound events. Used by setup and close before or outside indexed Source access.
AppContextPluginContext plus grouped jobs, sources, graph, actors, access, assets, compile, schema, yaml, rewriteHtml, invalid, and view(session, options?). Trusted, request-independent access.
DocumentRouteContextAdmitted view and public location; passed to declarative document renderers with app.
ServerContextAppContext plus request, session admission, HTTP serving, and trusted mutations. See routes below.
PluginCommandContextPlugin identity, repository roots, log, optional database, running-host status, file installation, and lazy viewer-safe inspection.

app.jobs.list({ limit }) and app.jobs.retry(id) provide Plugin-scoped live durable job operations. The Plugin authorizes callers; retry is immediate and separate from Source transactions.

Common AppContext reads include app.sources.get(idOrPath), all(), resolve(pathname), discoverable(view), and isPage(source). app.graph.relationsFrom(sourceId) and relationsTo(sourceId) read durable relations. These trusted repository reads are not automatically viewer-filtered. Inside a component's compile or load hook, these graph calls automatically record a dependency on the queried Source's outgoing or incoming relations—even when the result is empty. Core invalidates that consumer when the derived relation result changes, including relations supplied by other Plugins and unresolved targets that appear later. A prose-only edit that leaves those relations unchanged does not invalidate a graph-query dependency.

load({ page, app, view }) {
  return app.graph.relationsTo(page.id)
    .filter(edge => view.readSource(edge.from))
}

Keep metadata and access reads through the View: graph-query tracking does not replace those dependencies or grant access. Calls outside component compile/load hooks are ordinary repository reads with no automatic hook dependency tracking.

Inside a component's load hook, view.readSource(id) automatically tracks the read Source for live updates, including missing or denied reads. A component reading another Source needs no extra depend() call or trusted repository lookup to recover its storage path:

load({ view }) {
  return { title: view.readSource("/tour.page")?.data.title ?? "Unavailable" }
}

For visitor output, prefer ctx.view.readSource(id) and View queries; see Views.

app.access.can(principal, target, capability) makes an explicit access check. app.assets.read(source) reads an authored asset and requires an appropriate embedding access check by the caller. publishDerived(source, name, blob) retains the original asset's access boundary. app.assets.publish(...) publishes derived assets; do not use it to expose private data as public bytes.

Policy Plugins may synchronously implement access(app) to supply the single base Source access policy, grants(target, { principal }, app) to return additional capabilities or undefined, and context(source, { principal, subject }, app) to return a View restriction, false to deny, or undefined to decline. contextualRelations(source, ctx, app) may asynchronously return relations for an explicit View representation. These hooks belong to host/domain policy; ordinary components use the admitted View. A requested subject requires an explicit matching subject in a returned restriction. See reviewing another Actor’s saved work for the read-only subject selection contract.

HTTP routes

Create plugins/health.plugin.ts:

import { definePlugin, route } from "@pathmx/core"

export default definePlugin({
  id: "health",
  routes: [route.get("status", () => Response.json({ ok: true }))],
})

On a live server, GET /api/health/status returns {"ok":true}. route.get, post, put, patch, delete, options, and all each accept (path, handler). The handler receives ServerContext and returns a Response or Promise<Response>. Relative paths mount below /api/<plugin-id>/; a leading / declares an application-public path. For example, route.get("/health", ...) serves /health. Both forms honor the host mount. A namespaced handler's trailing /* matches descendants; public wildcards are not supported. GET also handles HEAD. A path with no matching method returns 405. Paths must be canonical, without query strings, fragments, traversal, or encoded separators.

Application-wide handlers run before credential resolution. They do not implicitly authorize the caller. ctx.request and ctx.url describe the request. ctx.path is relative to the Plugin mount for namespaced handlers and the pathname for public handlers; await ctx.session() resolves admission, and await ctx.readAccess(source) checks that request's read access. To construct a viewer-filtered query:

route.get("pages", async (ctx) => {
  const view = ctx.view(await ctx.session())
  return Response.json(await view.query("sources", { from: "/" }), {
    headers: { "Cache-Control": "private, no-store" },
  })
})

This is a route entry to add to a Plugin's routes array. Do not return ctx.sources.all() to a visitor: it is a trusted repository read. ctx.serve(view, { basePath }) serves an admitted View under a mount. ctx.actorHome() and ctx.actorAssets.create(relativePath, blob) / delete(path) operate on the admitted Actor's Home.

await ctx.mutate(async tx => { ... }) commits through the Source transaction pipeline. It is a trusted, unscoped server capability: authenticate and authorize the request before using it. Prefer Actions for normal user operations. A live route is not automatically a static endpoint; publish explicitly contributes finite public GET snapshots when needed.

The Bun CLI host uses a ten-second idle timeout for ordinary HTTP requests. A handler that waits without sending a response can have its connection closed before it returns; the Core live stream is exempt. Return a pending status promptly and let the client check progress, or use a durable job for background work. A custom server owns its transport timeout; other hosting adapters may have different limits.

Refresh Plugin state

A component or query that reads Plugin-owned state records its dependency:

ctx.depend({ type: "plugin", plugin: "applause", key: "room" })

After a route commits a change to that state, call ctx.changed("room") before returning its response. The Plugin id comes from the route's owner; the route supplies only its local key. For example, an authenticated same-origin route in an applause Plugin can write its SQL table and refresh the count:

route.post("clap", async (ctx) => {
  if (ctx.request.headers.get("Origin") !== ctx.url.origin)
    return new Response("A same-origin request is required.", { status: 403 })
  if ((await ctx.session()).type !== "authenticated")
    return new Response("Sign in to clap.", { status: 401 })
  const db = ctx.database!
  await db.run(`INSERT INTO ${db.tablePrefix}claps DEFAULT VALUES`)
  ctx.changed("room")
  return new Response(null, { status: 204 })
})

The table and its migration belong to that Plugin. Authorize the domain operation before writing, and call changed only after the write succeeds. The method returns void: it invalidates dependent query and compiled caches immediately and refreshes affected views through the existing live connection. Fresh HTTP reads work without a live connection; an old in-flight read cannot restore a valid stale cache.

Keys carry no state or authority. Each viewer re-reads with current admission and the Plugin's normal filtering. Notifications may coalesce, so the projection must represent current state rather than depend on receiving every write. A reconnected viewer reads current state; this is no durable event log. The invalidation reaches readers of this host process. Multiple hosts need their own coordination for shared external storage.

changed belongs to route ServerContext. Actions, jobs and CLI contexts retain their existing completion and invalidation contracts.

Documents

Declare deterministic documents once for live serving and static publication:

import { definePlugin, route } from "@pathmx/core"

export default definePlugin({
  id: "catalog",
  routes: [
    route.document("/catalog.txt", {
      contentType: "text/plain; charset=utf-8",
      headers: { "Cache-Control": "public, max-age=300" },
      fallback: "authored",
      async render(ctx, app) {
        return (await app.sources.discoverable(ctx.view))
          .map((source) => ctx.location.url(source.path).href)
          .join("\n")
      },
    }),
  ],
})

render(ctx, app) returns a standard response body or null, directly or as a Promise. Core owns status, content type, HEAD handling, and publication. The renderer receives no request or mutation methods. Output must depend only on its admitted Sources and location; use a handler for request-specific output.

Application documents use an anonymous View, including when the visitor is signed in. Source-owned documents use the admitted View live and an anonymous View during publication. A mounted View also restricts document reads. A subject-selected observation retains its admitted View for both kinds of document, so every read keeps the reviewer, subject and context restrictions. ctx.location provides origin (a URL), basePath, pathname(path), and url(path); use it to construct URLs that honor the live or publication mount.

fallback: "authored" lets an exact authored file or page replace this document. It does not suppress conflicts between Plugins or ambiguous Source ownership. The built-in llms.txt, robots.txt, and sitemap.xml use this policy.

Source-owned redirects

Create plugins/short.plugin.ts:

import { definePlugin, route } from "@pathmx/core"

export default definePlugin({
  id: "short",
  sourceRoutes(source) {
    if (typeof source.data.short !== "string") return []
    return [
      route.redirect(`/${source.data.short}`, {
        source: source.id,
        params: { via: "qr", remove: null },
      }),
    ]
  },
})

With short: welcome on paths/guides/start.page.md, a GET to /welcome?ref=a&ref=b&via=old&remove=1 returns a temporary 307 redirect to /guides/start.page?ref=a&ref=b&via=qr, with Cache-Control: no-store. Incoming keys and repeated values survive unless params replaces them with an authored string or removes every occurrence with null. Incoming parameters cannot select the destination Source. Core derives the canonical page URL from source, which is a page Source id. HEAD returns the same headers with no body; other methods return 405.

A redirect can also define onRedirect({ request, source, destination }, app) for live analytics. Core awaits the callback after admission on GET only, logs failures, and preserves the redirect. The callback cannot replace the response. HEAD, denied requests, and static publication never invoke it. A callback that needs SQL must use its Plugin's declared database; the route itself requires no SQL.

Static publication automatically creates an HTML redirect page for each public alias with a public target. Its small script applies the same query rules. The fallback link works without JavaScript and includes authored parameters; it cannot merge the incoming query string without JavaScript. Static output does not record live visits.

Ownership and updates

routes and sourceRoutes(source) accept documents, redirects, and live handlers. sourceRoutes is pure and synchronous; derive declarations from the indexed Source and keep request work in the handler or callback. Core removes previous claims on Source edits, moves, or deletion and replaces transaction batches atomically. The hook's Source owns every returned route. Core checks that owner before invoking a Source handler or document; redirects also require access to their target. An alias grants no access. Unavailable Sources follow the normal 404 behavior, with private response handling for authenticated reads.

Public claims cannot overlap ordinary Sources, assets, or reserved Core paths (/api, /actions, /.pmx, and /~). Conflicts identify their owners in lint and block publication. Source transactions reject newly introduced conflicts before writing. A conflicting direct file edit keeps the server running but makes the ambiguous route unavailable, without retaining the previous destination.

ctx.serve(view, { basePath }) serves declarative documents and redirects under that View's restrictions. It does not redispatch live Plugin handlers with an unrestricted server context.

Credential and policy providers

Most Plugins should use Auth and the admitted View. A custom admission Plugin can contribute Core's final CredentialHost contract directly. Provider and admission composition belongs to the portable @pathmx/auth/credentials entry point, including createCredentialHost(provider, admission).

Provider methodContract
signInGET link { method: "get", href } or null.
ready(directory)Initialize with a CredentialDirectory; returns Promise<void>.
resolve(request)Return { binding: { actor, identity }, signOut } or undefined. Identity contains provider, subject, optional email and claims; signOut is a POST link or null.
fetch(request, directory, context?)Handle provider endpoints and return a Promise<Response>. Persist the prepared Actor binding with successful proof/session state.
close()Release resources; returns Promise<void>.

ActorAdmission.bind(actors) returns a directory with prepare(identity) and admit(binding). prepare resolves a current { actor, identity, meta } candidate before proof delivery and again when proof is accepted. admit rechecks the exact Actor binding retained by a session. Authentication proves an identity; admission determines whether that binding currently belongs to this app. Re-resolving an email after session creation must never retarget an old credential to another Actor.

A direct CredentialHost implements async ready(actors), resolve(request, actors, context?), fetch(request, actors, context?), and close(). The optional request context provides the trusted socket peerAddress and a hosting-adapter-resolved clientAddress, never unchecked forwarding headers. Routes can delegate credential handling through ctx.handleCredentials().

A base access policy returned by access(app) implements synchronous resolve(principal, target, inspect?), authorize(principal, change), and canEmbed(container, dependency). resolve returns visibility (public, private, or denied), capabilities (read, write, execute), and optional blocked, owning target, and reason. authorize validates a proposed create/update/delete and throws to reject it. Optional home, homeActorId, diagnostics, and validate support personal paths and policy validation. A Plugin-level validateSources(prepared, app) hook rejects invalid cross-Source final states before trusted writes. prepared.changes preserves operation intent and parsed before/after Sources; prepared.get() reads through the proposed final repository. Only one base policy can be installed.

Supplemental grants stay beneath View restrictions and cannot bypass a blocked base-policy target. A context restriction may implement read(source), actions({ name, target }), and effects({ operation, id }) predicates. Restrictions narrow authority; they do not grant it. Verify these integrations with denied, anonymous, and admitted callers before exposing their routes.

Assets, transforms, and publication

addAsset(...assets) accepts the following document asset shapes:

TypeFields
stylesheethref and optional media; or authored source, optional media and layer; or generated name, text, optional media and layer. Generated CSS defaults to pmx.plugins.
styleInline text.
scripthref, authored source, or inline text with optional name; all accept optional module.
preloadhref, as, optional contentType and crossorigin.
titletext.
metaattrs, a string record such as { name: "description", content: "..." }.

link assets accept an attrs string record, for example:

ctx.addAsset({
  type: "link",
  attrs: { rel: "alternate", type: "text/markdown", href: "/index.page.md" },
})

Attribute values are escaped. Links with identical attributes deduplicate regardless of attribute order; distinct alternate representations remain separate. Use stylesheet and preload assets for their existing bundling and loading behavior.

Prefer addBrowserBundle(entrypoint, name) for TypeScript, TSX, and imported CSS. It atomically contributes the bundle's JavaScript and initial styles. Keep secrets out of browser entrypoints and imported modules.

app.assets.load(url) loads through the environment, and publish(name, blob, contentType?) returns a published asset URL.

Documents and redirects declared with route.document and route.redirect automatically contribute their finite static representations. For example:

import { definePlugin, route } from "@pathmx/core"

export default definePlugin({
  id: "public-status",
  routes: [
    route.document("status", {
      contentType: "application/json",
      render: () => JSON.stringify({ ok: true }),
    }),
  ],
})

Save as plugins/public-status.plugin.ts. /api/public-status/status returns the same JSON live and in a static publication without a second registration.

A Plugin's publish(ctx, app) hook can report ctx.unsupported(source, message) when an authored use has no static representation. For a live handler that also supports an explicit anonymous snapshot, ctx.route(pathname) opts that finite GET response into publication. It does not make request-dependent handlers or mutations work on a static host. Prefer declarative documents for deterministic content. See publishing for host and output choices.

SQL storage

Declare database only when the Plugin needs private operational state. Use Sources and Actions for authored or personal content. The host must support SQL; the Bun host stores it at db/<plugin-id>.sqlite beside the project manifest. db/ is durable and .pathmx/ is disposable generated output.

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "sync-status",
  database: {
    migrations: [
      {
        id: "001-status",
        sql: (prefix) => `CREATE TABLE ${prefix}status (name TEXT PRIMARY KEY, value TEXT NOT NULL);`,
      },
    ],
  },
  async setup({ database }) {
    if (!database) throw new Error("sync-status requires its declared database")
    await database.run(
      `INSERT OR IGNORE INTO ${database.tablePrefix}status (name, value) VALUES (?, ?)`,
      ["sync", "idle"],
    )
  },
})

SqlDatabase is available only to its owning Plugin through supported contexts.

MethodResult
all<Row>(sql, params?)Promise of readonly rows.
get<Row>(sql, params?)Promise of one row or undefined.
run(sql, params?)Promise of { changes, lastInsertRowId? }.
exec(sql)Promise of void; execute SQL without parameters.
transaction(async database => result)Promise of the callback result within a database transaction.

Parameters are an array or named record of string, number, bigint, Uint8Array, or null. Bind values instead of interpolating SQL. Migrations have unique ids and nonempty SQL; append migrations and never edit, remove, or reorder applied history. PathMX opens databases lazily, applies migrations transactionally, and closes connections with the app. Ignore private db/ in Git and back it up when continuity matters.

database.tablePrefix is an empty string for separate Bun databases and a host-validated identifier prefix for shared SQL storage. Prefix every owned table and index in migrations and queries. Migration factories receive this prefix; their empty-prefix SQL determines the migration checksum. Hosts requiring a prefix reject plain-string migrations. Bind data values as parameters; only the host-provided identifier prefix belongs in interpolated SQL.

Bun hosts resolve an explicit stateRoot, then PATHMX_DB_DIR, then the project db/ directory. Relative values resolve against the project root. Use the same location for the server and CLI; portable Core receives its resolved location without reading process environment. A standalone Source directory with no owning package and a name other than paths uses its own sibling <directory-name>.db/, so unrelated directories cannot share durable state accidentally. For example:

import { PathMX } from "@pathmx/core/bun"

const app = await PathMX({
  root: "/srv/course/paths",
  stateRoot: "/srv/course-db",
})

Administrative commands for this host use PATHMX_DB_DIR=/srv/course-db bun run pmx analytics status when Analytics is installed. Keep SQL storage outside Sources and disposable output.

CLI commands and file installation

Create plugins/reading-admin.plugin.ts:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "reading-admin",
  commands(group, ctx) {
    group.command("pages").action(async () => {
      const { view } = await ctx.inspect()
      console.log(
        JSON.stringify(await view.query("sources", { from: "/" }), null, 2),
      )
    })
  },
})

Run bun run pmx reading-admin pages. group is a Commander command mounted below the Plugin id. ctx.inspect(actor?) lazily opens the local project. Its view simulates anonymous access by default, or the supplied Actor. Local administrator inspection separately exposes all repository Sources through sources.get(id), sources.all(), and sources.resolve(pathname), including Sources that the simulated View cannot read. pagePath(id) returns a page-capable Source's canonical path; routeDiagnostics() reports route conflicts. events.definitions() exposes registered Event metadata. The host closes the app when the command ends.

Use ctx.mutate() to change Sources from a local administrator command. It stages a Source transaction, validates the resulting project, checks for stale storage, and commits through Core:

await ctx.mutate(async tx => {
  const source = await tx.readSource("/reading/index")
  tx.source(source.id).data({ title: "Reading room" })
})

Pass { dryRun: true } as the second argument to stage and validate the same transaction without committing it:

await ctx.mutate(async tx => {
  await tx.create("/reading/new.lesson", "# A new lesson\n")
}, { dryRun: true })

Dry runs check Source and Block schemas, final-state Plugin validators, Source identity conflicts, and current storage. They return an empty applied list and do not update the repository or publish live changes. The callback must stage its writes through tx; the option cannot undo unrelated side effects inside the callback. A preview does not reserve its destinations for a later command.

While pmx dev or pmx serve runs for the repository, the command still runs locally, but the server commits its transaction. The callback plans against the command's index. The server commits only if every Source the plan read is unchanged; otherwise the command fails and can be rerun. Later plans and ctx.inspect() in the same command see each committed result. Before loading Plugins for inspection or opening a command's database, the CLI confirms the server is reachable and uses the same state directory. A mismatch fails before Plugin setup or command code can write local state. ctx.events.emit() likewise admits Events through that server, whose worker runs the resulting jobs. ctx.host is true in that case. Leave external effects to the server's jobs then, rather than performing them in the command:

await ctx.events.emit(Queued, { delivery: id })
if (!ctx.host) await deliverNow(id)

This local repository authority is independent of the inspection View's Actor. It does not represent an authenticated server request. Keep Actor-bound server mutations in server.mutate(); use the command transaction for project maintenance.

The command context also exposes pathsRoot, projectRoot, stateRoot, plugin, log, host, and the optional isolated database. To scaffold editable files, call this inside a command action:

await ctx.installFiles(
  [{ root: "paths", path: "reading/index.md", content: "# Reading\n" }],
  { existing: "preserve" },
)

root is paths or project. Installation rejects duplicate or escaping paths and rolls back partial writes. Different existing content is an error by default; existing: "preserve" keeps it. Results contain destination and a status of created, unchanged, or preserved.

Published server slices

A Plugin can declare a portable server slice for a host that serves static pages. The host selects it explicitly; ordinary export does not instantiate it or open its database. publishServer(app) receives trusted authoring context and returns:

publishServer(app) {
  return {
    module: new URL("./published-server.ts", import.meta.url),
    data: publicSnapshot(app),
    database: { migrations: [{ id: "001", sql: "CREATE TABLE counts (name TEXT PRIMARY KEY, count INTEGER);" }] },
    routes: [{ method: "POST", path: "/api/counts/collect" }],
  }
}

This is a hook on a Plugin whose ID is counts; publicSnapshot is that Plugin's own admission-aware projection. Keep private Sources and executable authoring schemas out of the snapshot. The module exports create(data, database) returning fetch(request) and, optionally, inspect(request) for host-authenticated reads and maintain() for bounded durable maintenance. Types are exported from @pathmx/core/publish. The runtime gets the projected snapshot and a SqlDatabase, not a second repository engine. Database migrations use the shared checksum ledger through migrateDatabase from @pathmx/core/database.

A supporting host sets runtime.has(pluginId, "published-server") during its publication pass. A Plugin may include its client after its server snapshot has been prepared; the capability does not permit collection during export. Routes must remain under /api/<plugin-id>/. Live Actions, credentials, Source writes, and arbitrary live hooks are not implied by this declaration.

The Cloudflare adapter supplies SQLite-backed Durable Objects with serialized SQL operations, rollback-capable asynchronous transactions, and positional SQL parameters. Named parameters, nested transactions, and integers outside JavaScript's safe range fail explicitly. The Analytics Plugin is a complete consumer using the same browser protocol, admission projection, migrations, and query functions on Bun and Cloudflare.