Actions - developerz-ai/ultimate GitHub Wiki
The load-bearing primitive. A mutation or command, server-authoritative, declared once.
export const publishPost = action({
input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
output: PostView,
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
cache: { invalidates: [tag.post, tag.feed] },
mcp: { expose: true, description: 'Publish a draft post' },
async handle({ input, ctx }) {
const post = await ctx.posts.publish(input.postId);
if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: post.orgId });
return post;
},
});Declared one per file in a feature's actions/ (actions/publish-post.ts, what x g writes). Named export, never default. The export name is the identity, and it must be globally unique. Only the HTTP path is derived from it (publishPost โ POST /api/posts/publish); the OpenAPI operationId and the MCP tool name are the export name verbatim โ publishPost, never publish_post. As of 2026-08 that holds on every surface at once: what tools/call accepts, what defineAppMcp's scopes: map is keyed on, what the LLM tool list offers, what openapi.json's x-ultimate.mcpTool publishes, and what describe().mcp.tool reports.
| Field | Type | Required | Meaning |
|---|---|---|---|
input |
Standard Schema (t from @ultimat3/schema) |
yes | parsed before anything else runs; drives the TS type, JSON Schema, OpenAPI request body, MCP tool schema. t is the shipped dependency-free builtin provider; ArkType, Zod and Valibot are optional swaps behind configureSchemaProvider and ship no adapter |
output |
Standard Schema | yes | the response contract; drives the typed client return type and the OpenAPI response |
policy |
Policy from can(...)
|
yes | the one authz decision, evaluated on every surface. Omitting it is a build error |
cache.invalidates |
readonly CacheTag[] |
no | tags dropped from every cache tier after handle settles โ and, for an action invoked inside withTransaction, only once the root transaction commits (never on rollback). Unknown tag = compile error |
mcp.expose |
boolean |
no (opt-in) | only a literal true makes the action a tool; silence exposes nothing. One predicate answers it everywhere โ the tool, the LLM tool list, describe().mcp.expose, and openapi.json's x-ultimate.mcpTool (which is null when there is no tool). Listing an un-exposed action in defineAppMcp is X_MCP_TOOL_UNDECLARED at boot |
mcp.description |
string |
no | the tool description an agent reads, and the OpenAPI summary. Contract text, so it stays outside t() โ openapi.json must not depend on a locale. Write it for a stranger |
mcp.visibleTo |
readonly string[] |
no | roles that may see the projected tool; a caller whose role is not named gets ToolNotFound, never Forbidden โ the policy still decides every call |
rateLimit |
{ limit: number; windowMs: number } |
no | spent inside invoke, so the HTTP route, the MCP tool, the in-app agent's tool call and .job() draw on ONE bucket โ the third call to a limit: 2 action is X_RATE_LIMITED whichever surface it came through; a direct in-process call spends nothing. Keyed actor โ org โ IP; counted in @ultimat3/http's installed store (installRateLimitStore(), filled by the boot on every role with the same instance httpServer({ rateLimitStore }) holds). Over HTTP the refusal is 429 + Retry-After and RateLimit-*, and the route is not also capped by the default bucket; the same toBucket conversion feeds openapi.json, so the published numbers are the enforced ones. /mcp additionally buckets by verb class (MCP_RATE_LIMITS, read 120/min, write 20/min per token) |
idempotent |
boolean |
no โ required true on a mutator
|
marks the action safe to retry with an Idempotency-Key header โ over MCP, the tool's optional idempotencyKey argument. Inside a transaction the Postgres store settles the record with the write's own COMMIT, so a rollback never leaves a settled record behind. A field core redacts by name (apiKey, password, *Tokenโฆ) or a Secret is stored as [redacted], and the replay of such an answer is X_IDEMPOTENT_REPLAY_REDACTED
|
http.path |
string |
no | pins the URL whatever the app's pathStyle derives โ for a path a vendor holds ('/api/webhooks/wompi'). Static, lowercase, no params, never /_x (X_ACTION_HTTP_PATH_INVALID) |
handle({ input, ctx }) |
(args) => Promise<Output> |
yes | the body. Parsed input, ambient ctx. Returns output-shaped data |
ctx members an action may use:
| Member | Type | Use |
|---|---|---|
ctx.actor |
Actor |
{ kind: 'user' | 'service' | 'agent' | 'anonymous', id, orgId?, roles, scopes }. Read-only; authz already ran |
ctx.<service> |
app-augmented | repos and services (ctx.posts, ctx.orgs, ctx.mail) โ declared via CtxServices, registered with defineService. On an HTTP request each is built lazily, on first read, for the authenticated actor, and rebuilt if the actor, locale or time zone changes (packages/http/src/request-services.ts). From 13.0.0 until 21.0.0 they were built before the auth stage and acted as the anonymous actor |
ctx.jobs |
job client | the facade <job>.enqueue(input) resolves; enqueue is transactional via the outbox |
ctx.requestId / ctx.traceId
|
string |
W3C trace id; the same value crosses HTTP โ job โ live query |
ctx.locale / ctx.tz
|
string |
BCP-47 and IANA. Never format a date without ctx.tz
|
ctx.clock / ctx.now()
|
Clock / Date
|
frozen and advanceable in tests. Never Date.now()
|
ctx.logger |
Logger |
structured, role-tagged, correlated to the span |
ctx.signal |
AbortSignal |
client disconnect / drain; pass it to long calls |
ctx.role / ctx.buildId
|
Role / string
|
which runtime role is executing, and which build |
Every projection this package owns is a method on the action โ publishPost.openapi(), never toOpenApiOperation(publishPost). The MCP tool is the exception by tier: @ultimat3/mcp's one projection, toolFrom(publishPost) (As of 25.0.0 there is no .tool()). Exactly four declared fields are lifted onto it as readable properties: input, output, policy, mcp. The rest of the declaration is structured metadata, reachable through describe() and nowhere else. An action has no .def.
| Member | Is | Rule |
|---|---|---|
publishPost(input, options?) |
the mutation | parse input โ load row โ evaluate policy โ handle โ parse output
|
.as(actor, input, options?) |
the same mutation, as someone else | keeps the surrounding context whole โ services, clock, locale, trace โ and swaps only the actor. null is the signed-out caller |
.openapi() |
the OpenAPI 3.1 operation | byte-stable; x verify diffs it for contract drift |
.client({ baseUrl }) |
the typed RPC method | derives POST /api/posts/publish by string math, so the browser imports no server code |
.job() |
the durable-work handle | the same handler, run through the queue as action:publishPost
|
.contract() |
the generated assertions | garbage rejected, anonymous denied, spec present |
.describe() |
the manifest row |
kind, name, verb, resource, method, path, capability, input, output, invalidates, idempotent, mutator, mcp, rateLimit โ and the only reader for the declared metadata that is not lifted |
.input .output .policy .mcp
|
the declaration, lifted | the whole lifted set โ readable, and .mcp present only when declared. cache.invalidates, rateLimit and idempotent are not properties: read them off describe(), where cache.invalidates flattens to invalidates
|
handle and row โ the declaration's two functions โ are unreachable by design: neither lifted nor described. The declaration lives in a private store inside invoke.ts and @ultimat3/action exports no reader for it, so invoke is the only thing that can run them โ one execution path and one authz path, structurally rather than by convention. A hand-rolled object with kind: 'action' is X_ACTION_FOREIGN, never a registered action.
Every projection needs the name registerActions() stamps on. It names the export in place, so import { publishPost } is the action that projects once the app has booted โ there is no second, differently-named twin to remember.
| # | Artifact | Derived from | Notes |
|---|---|---|---|
| 1 | HTTP route | name + input
|
POST /api/posts/publish โ first word is the verb, the rest is the pluralized resource (the default pathStyle: 'resource'); defineApi({ http: { pathStyle: 'readable' } }) makes it /api/publish-post, the kebab-cased name, and http.path pins one. Body parsed by input, errors are UltimateError JSON |
| 2 | OpenAPI operation |
input + output + mcp.description
|
publishPost.openapi(), emitted into x.manifest.json and openapi.json; contract diff runs in x verify
|
| 3 | Typed client function |
input + output
|
one map-wide client, export const client = rpc<Api['actions']>({ baseUrl }), then await client.publishPost({ postId }) in app/; or publishPost.client({ baseUrl }) for a single method. rpc is the only name for the map-wide client โ no createClient alias, no fetch, no codegen step to remember. x new writes it as apps/web/shared/browser-client.ts (browserClient, beside browserQueries); it typechecks at any app size (pinned at 300 actions in 100 modules) |
| 4 | Job handle | the whole declaration |
publishPost.job() โ a namespaced name, an idempotencyKey from the payload, and an invoke that runs the same handler durably. Register it with the queue; .enqueue() belongs to a declared job
|
| 5 | MCP tool |
mcp + input + policy
|
toolFrom(publishPost) from @ultimat3/mcp โ one tool named publishPost per exposed action, exactly the tools/list entry, JSON Schema from input, every call through this action's invoke (authz unchanged) |
| 6 | Test |
input + policy
|
publishPost.contract() โ schema round-trip plus a denial test per policy branch, generated green, not as a TODO
|
Plus cache invalidation: cache.invalidates fans out to request memo, in-process LRU (all instances, over NATS), Redis, ISR pages, and the CDN purge webhook in one hop (Caching and invalidation).
There is one execution path. HTTP, MCP, jobs, and direct server calls differ only in the surface they declare, which selects how a denial is rendered โ never whether authz runs and never how input is validated.
As of 22.6.0. Declared once, beside the primitives, and read by the boot and x manifest:
| Key | Effect |
|---|---|
http.pathStyle |
'resource' (default โ every existing URL stays) or 'readable': signIn โ /api/sign-in, health โ /api/health. A browser states nothing: the server stamps a non-default style into every document (<meta name="ultimate-path-style">) and rpc({ baseUrl: '' }), useMutation and the outbox replay derive under it. A caller with no document โ a script, another service โ passes rpc({ baseUrl, pathStyle: 'readable' }); the wrong one is answered 404 X_CONTRACT_DRIFT naming the style served and the action's real path, never a bare X_ROUTE_NOT_FOUND. The module scan evaluates apps/web/api/index.ts first, so a module-level derivePath(name) anywhere else sees the declared style; one evaluated earlier (inside the index's own imports) is refused at the declaration, X_ACTION_PATH_DERIVED_EARLY โ derive inside the function that uses it |
http.mounts[] |
{ prefix: '/v1', scopes, resolveToken, rateLimit?, openapi? } โ a second door onto a cut of the same routes. Only Authorization: Bearer authenticates there (a session cookie reaches nothing, so no CSRF proof is needed); a primitive outside the token's scopes answers 404 like MCP's unknown tool; no/invalid token 401 with WWW-Authenticate: Bearer; a per-token allowance answers RateLimit-* and 429 + Retry-After. scopes is the map defineAppMcp takes; resolveToken the same resolver. /api/create-case โ /v1/create-case, /_x/query/case-list โ GET /v1/case-list
|
http.mounts[].openapi |
a file ('openapi.v1.json') x manifest writes with only the cut, bearer-secured; x verify refuses it stale or missing. Each operation's security is [{ bearer: ['<its scope>'] }] โ the one scope of scopes that names it (also x-ultimate.scope) โ and components.securitySchemes.bearer lists every scope with the operations it unlocks, in its description and as x-ultimate.scopes ({ scope: [names] }). As of 22.12; before, every operation said bearer: []
|
openapi |
{ title, version, description, servers, sessionCookie } โ any key makes openapi.json complete: info, servers, securitySchemes (cookie, plus bearer with a mount), 401 + 429 (with Retry-After) on every operation its route authenticates, RateLimit-* on rate-limited ones. Absent, the bytes are unchanged |
policy is evaluated for the HTTP call, the typed client call, the job execution, the MCP tool call, and the live-query subscription โ the same function, the same actor resolution, the same denial error.
Two authz systems is how every Meteor-like framework died. Meteor had
allow/denyrules for the client sync path and plain method bodies for the server path; the two drifted, and the drift was the security model. Anything that lets an agent expose data through a second door โ a "public" MCP tool, an "internal" RPC, a sync rule table โ is a rejected design, not a config option.
Rejected by that rule: an MCP-specific permission table, a "trusted tool" mode, a broad-rights service account for agents, and an internal RPC surface that skips policy. Details in Policies and authz.
- read the request object, headers, or cookies directly โ actor and tenant come from
ctx. One exception is a sender whose credential is a signature over the raw body. Amazon SNS poststext/plain, and a payment gateway signs a header over the bytes. The action gets the body parsed (atext/*body as a decoded string), anduseRequestBodyBytes()from@ultimat3/httpreturns the exact bytes that body was parsed from. The read is the same size-capped and cached one, so reading both costs one read. The policy staysallow('public'), and the signature check inhandleauthenticates the sender. Off HTTP (a job, MCP) it isX_NO_REQUEST - render, redirect, or return HTML
- perform its own authorization inside
handleโ that belongs inpolicy - do slow work inline โ enqueue a
job - share a file with another primitive โ one per file, in the feature's
actions/
Logic lives in service.ts; actions/ holds declarations. An action whose handle is more than a few lines of orchestration is a service waiting to be extracted.
Same declaration surface as action, plus a local half that runs client-side against the local store while the server call is in flight.
export const likePost = mutator({
// REQUIRED (`X_MUTATOR_NOT_IDEMPOTENT`): a replay under the same Idempotency-Key answers the
// first result instead of running `server` again.
idempotent: true,
// Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
// equal applying it once โ `likedByMe` is what makes the second application a no-op.
local(tx, { postId }) {
tx.posts.update(postId, (p) =>
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
},
async server(ctx, { postId }) { return ctx.posts.like(postId); },
conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
});| Aspect | Rule |
|---|---|
| Projects to | everything action does, plus a local-store transaction and a rebase entry |
| Owns | conflict strategy: 'server-wins', 'last-write-wins', or custom(merge)
|
| Authz | the server half carries the policy; the local half is presentation only and never a security boundary |
| Idempotency |
idempotent: true is required โ a compile error, and X_MUTATOR_NOT_IDEMPOTENT at declaration. The client replays a mutator's write under one Idempotency-Key (a retry, the offline queue draining), and the replay answers the first result instead of running server again |
| Never | let local do I/O, randomness, or Date.now()
|
Replayability rule: local is re-executed on every rebase โ after each server confirmation, on reconnect, and when a conflicting remote write arrives. It must be a pure function of (tx, input). I/O, Math.random(), crypto.randomUUID(), or a wall-clock read makes the local timeline diverge from the server's, and the divergence surfaces as flicker, then as wrong data. Ids and timestamps come from the input, generated once at call time. Tier 3 local-first (persist: true) has not shipped, As of 2026-08; mutators work today at realtime tiers 1โ2 (Realtime).
| Code | When | Fix |
|---|---|---|
X_ACTION_POLICY_MISSING |
an action registered without policy
|
add policy: can('<name>') to the declaration |
X_ACTION_DUPLICATE |
two actions share an export name | rename one โ names are globally unique |
X_INPUT_INVALID |
body fails the input schema โ including a string (or a t.record key) carrying U+0000, which every string-backed t refuses because Postgres text/jsonb cannot store it. Tabs, newlines and the other C0 controls are accepted. As of 2026-09-29
|
x actions show <name> --json prints the expected schema |
X_FORBIDDEN |
the policy said no โ one code for the direct call, the HTTP 403 and the MCP tool error | grant the capability, or call as an actor who has it |
X_UNAUTHENTICATED |
no session; anonymous actor hit a policy needing one (401) | sign in, or send a valid token |
X_IDEMPOTENCY_CONFLICT |
key reused with a different payload, or still in flight | send a fresh Idempotency-Key, or retry after the first settles |
X_IDEMPOTENT_REPLAY_REDACTED |
a replay of an answer whose secret-named field (isRedactedKey, or a Secret) was stored as [redacted] โ the first call ran, its answer is not kept in plaintext (409) |
read the effect with a query; a new Idempotency-Key runs the call again |
X_IDEMPOTENCY_RESERVATION_LOST |
an idempotent action inside a transaction outlived the request deadline and a retry took its key; its transaction rolls back (409) | resend with the same Idempotency-Key
|
X_MUTATOR_NOT_IDEMPOTENT |
a mutator declared without idempotent: true
|
add idempotent: true to the definition |
X_CONTRACT_DRIFT |
client build id โ server build id, or a breaking published-contract change | reload the client / bump the action version |
X_TENANCY_UNSCOPED |
a query inside handle had no tenant predicate |
scope it through the repo, never raw SQL |
X_BOUNDARY_VIOLATION |
action declared outside api/ or <feature>/actions/
|
move the file, or x fix boundary <file>
|
X_LAYOUT_SIBLING_MODULE |
a module actions.ts beside the directory actions/
|
move each declaration to actions/<name>.ts, then delete the file |
X_ACTION_POLICY_MISSING: action registered without a policy
cause: action "publishPost" was registered without a policy
fix: add `policy: can('publishPost')` to the action definition in the file that exports it
Every code carries the same { code, cause, fix, docs } in the terminal, the dev overlay, the HTTP body, --json, and the MCP tool error. Full list: Error codes.
$ x actions list --json
{"ok":true,"command":"actions","summary":"1 action","findings":[],
"data":[{"kind":"action","mutator":false,"name":"publishPost","verb":"publish",
"resource":"posts","method":"POST","path":"/api/posts/publish",
"capability":"post:publish", โฆ}]}
Both subcommands emit the same describe() row โ list one per action, show the named one. It is exactly what publishPost.describe() returns in process, so the CLI has no private view of a primitive:
$ x actions show publishPost --json
{"ok":true,"command":"actions","summary":"action publishPost","findings":[],
"data":{"kind":"action","mutator":false,"name":"publishPost","verb":"publish",
"resource":"posts","method":"POST","path":"/api/posts/publish",
"capability":"post:publish",
"input":{"type":"object","required":["postId"],
"properties":{"postId":{"type":"string","format":"uuid"},
"notify":{"type":"boolean","default":true}}},
"output":{"$ref":"#/components/schemas/PostView"},
"invalidates":["feed","post"],"idempotent":false,
"mcp":{"expose":true,"tool":"publishPost","description":"Publish a draft post"},
"rateLimit":null}}
invalidates is sorted and de-duplicated, so descriptor output never depends on declaration order โ a diffable contract. mutator is true only for a mutator() declaration; kind stays "action" for both, which is why the flag is a field of its own and not a second kind. The same data is the MCP actions.describe tool (actions and queries in one call) and the /_x Routes panel.
Emitted with the action, and green on the first run: it pins the invariant the action owns โ a non-owner is denied โ rather than a TODO someone has to notice.
// contract test โ generated with the action
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
const { post, stranger } = await seed('two-orgs');
await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
.rejects.toBeUltimateError('X_FORBIDDEN');
});Runs against a cloned Postgres (CREATE DATABASE โฆ TEMPLATE), never a mock. x test contract asserts the input/output schema round-trip, one denial per policy branch, and that the emitted OpenAPI operation plus MCP tool shape still match the declaration. See Testing.