Action
A typed command. Something a user — or another system — wants done. The verb of your domain.
Shape
import { defineAction } from "@nwire/forge";
import { z } from "zod";
export const submitAnswer = defineAction({
name: "submissions.submit-answer", // routing key — unique across the app
description: "Avi taps Submit on a Hebrew Letters exercise.",
persona: "Avi (9, beginner)", // Studio-aware: who triggers this
journeyStep: "J3-submit", // Studio-aware: journey position
capability: "submit-answer", // Studio-aware: capability tag
slo: { p95LatencyMs: 200, successRate: 0.999 },
tags: ["student-facing", "write-path"],
policy: "student-self", // optional authz tag
input: z.object({
submissionId: z.string(),
studentId: z.string(),
exerciseId: z.string(),
answer: z.string(),
}),
emits: [AnswerWasSubmitted], // declared intent — Studio draws edges
retry: { max: 3, backoff: "exponential", baseDelayMs: 100 },
// Inline handler — the common case where contract + handler ship together.
// Omit it for a schema-only contract you dispatch toward (cross-module).
handler: async ({ input }) =>
AnswerWasSubmitted({ ...input, submittedAt: new Date().toISOString() }),
});Two registration shapes
Inline (common case — handler ships next to the contract):
defineAction({ ..., handler: async (ctx) => Event(...) })Schema-only contract (when the contract is referenced from another module — e.g., mastery dispatches submissions.grade-submission). The caller imports a defineAction with no handler; the owning module declares the same action with its handler:
// submissions-contract/grade-submission.action.ts — the typed contract
export const gradeSubmission = defineAction({ name, schema, emits })
// submissions/grade-submission.action.ts — same name, with the handler
export const gradeSubmission = defineAction({
name, schema, emits,
handler: async (ctx) => SubmissionGraded({ ... }),
})An action IS a handler — registering the action registers its handler. The caller dispatches toward the contract via ctx.request(...).
What the handler can do
defineAction({
name: "submissions.submit-answer",
input: SubmitAnswerInput,
handler: async (ctx) => {
const { input } = ctx;
ctx.envelope.tenant // tenant id (multi-tenant scope)
ctx.envelope.userId // authenticated user id
ctx.logger.info(...) // envelope-scoped logger
// Read a projection (no mutation)
const history = await ctx.query(submissionsByStudent, { studentId: input.studentId })
// Dispatch another action — derived envelope, full causation chain
const grade = await ctx.request(scoreSubmission, { ... }) // ask + await the result
await ctx.send(notifyStudent, { ... }) // fire-and-forget → MessageRef
// Announce facts as side-effects
await ctx.emit(DraftWasScored, { ... }) // local — this context only
await ctx.publish(SubmissionWasGraded, { ... }) // cross-context — event must be .public()
// Load + use an actor (invariant enforcement)
const submission = await ctx.actor(Submission, input.submissionId)
return submission.flag(input.reason) // method returns event
// External boundary
const charge = await ctx.externalCall(chargeStripe, { ... })
return AnswerWasSubmitted({ ... }) // event the actor folds
},
})Two verbs announce facts, and they carry different reach. ctx.emit is local fanout — when listeners in this bounded context fire, and the event never crosses the boundary. ctx.publish announces a cross-context fact: the same local fold, then the outbound bus/sink leg. publish accepts only events marked .public() and throws otherwise, so an internal event can't leak by accident. See events and listeners.
Actions carry the same marker: submitAnswer.public() returns a clone marked as part of the app's contract, which a composed neighbour may dispatch with ctx.send. Everything unmarked stays internal to the app.
What the handler MUST NOT do:
- ❌ touch actor state directly — return an event; the actor's
assignfolds it - ❌ throw on validation errors that the schema should catch — let zod handle it
- ❌ side-effect outside the envelope (uncontrolled fetch / db write) — go through
ctx.externalCallso Studio sees it
Studio-aware metadata
The optional fields drive Studio's UX. None affect runtime behavior — they're intent declarations the framework reads at design time and the runtime scores observed reality against:
| Field | What Studio does with it |
|---|---|
persona | Groups actions by triggering human; renders persona journey strips |
journeyStep | Lays out the EventStorming canvas in causal time order |
capability | Filters / groups in the actions list |
slo: { p95LatencyMs, successRate } | Renders SLO scorecard with observed latency / success rate |
tags | Free-form filtering |
emits: [Event] | Draws Command → Event edges on the EventStorm canvas |
See also
- defineAction — full API reference
- Handler
- Event — what actions emit
- Actor — what events fold into
Convention for the next sections: each Concept page links to its Primitives reference for the full API, and to adjacent concepts.