Skip to content

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 assign folds 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.externalCall so 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:

FieldWhat Studio does with it
personaGroups actions by triggering human; renders persona journey strips
journeyStepLays out the EventStorming canvas in causal time order
capabilityFilters / groups in the actions list
slo: { p95LatencyMs, successRate }Renders SLO scorecard with observed latency / success rate
tagsFree-form filtering
emits: [Event]Draws Command → Event edges on the EventStorm canvas

See also

Convention for the next sections: each Concept page links to its Primitives reference for the full API, and to adjacent concepts.

MIT licensed.