Action
A typed command. Something a user — or another system — wants done. The verb of your domain.
Shape
ts
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
schema: z.object({
submissionId: z.string(),
studentId: z.string(),
exerciseId: z.string(),
answer: z.string(),
}),
emits: [AnswerSubmittedEvent], // declared intent — Studio draws edges
retry: { max: 3, backoff: "exponential", baseDelayMs: 100 },
// Optional inline handler — when not provided, register separately via
// `defineHandler(submitAnswer, fn)`.
handler: async (input) =>
AnswerSubmitted({ ...input, submittedAt: new Date().toISOString() }),
});Two registration shapes
Inline (common case — handler ships next to the contract):
ts
defineAction({ ..., handler: async (input, ctx) => Event(...) })Separated (when the contract is referenced from another module — e.g., mastery dispatches submissions.grade-submission):
ts
// submissions/grade-submission.action.ts — the typed contract
export const gradeSubmission = defineAction({ name, schema, emits })
// submissions/grade-submission.handler.ts — the implementation
export const gradeSubmissionHandler = defineHandler(gradeSubmission, async (input, ctx) => {
return SubmissionGraded({ ... })
})Both end up at the same place in the runtime; the choice is ergonomic.
What the handler can do
ts
defineHandler(submitAnswer, async (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
await ctx.request(scoreSubmission, { ... })
await ctx.send(notifyStudent, { ... }) // fire-and-forget variant
// Load + use an actor (invariant enforcement)
const submission = await ctx.use(Submission, input.submissionId)
return submission.flag(input.reason) // method returns event
// External boundary
const charge = await ctx.externalCall(chargeStripe, { ... })
return AnswerSubmitted({ ... }) // event the actor folds
})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.