defineAction
defineAction({ ... }) declares a typed command. The verb of your domain.
Signature
ts
import { defineAction } from "@nwire/forge"
function defineAction<TSchema extends ZodTypeAny>(
meta: ActionMeta<TSchema>,
): ActionDefinition<TSchema>ActionMeta
ts
interface ActionMeta<TSchema extends ZodTypeAny> {
// Required
name: string // routing key — unique across the app
schema: TSchema // zod schema; input is z.input<TSchema>
// Optional contract
description?: string // persona narrative — surfaces in Studio + OpenAPI
retry?: RetryPolicy // dispatcher applies retry+DLQ
policy?: string | string[] // authz tag — opaque to framework; consumed by @nwire/auth
emits?: readonly EventDefinition[] // declared events this action produces
// Studio-aware metadata (all optional, additive)
persona?: string // "Avi (9, beginner)" — Studio groups by persona
journeyStep?: string // "J3-submit-exercise" — drives EventStorm L2 layout
capability?: string // capability name for filtering/grouping
slo?: { p95LatencyMs?: number; successRate?: number }
tags?: readonly string[]
// Inline handler (alternative to separate defineHandler)
handler?: (input: z.output<TSchema>, ctx: HandlerContext) => Promise<HandlerReturn> | HandlerReturn
}
interface RetryPolicy {
max: number // retry attempts after initial try (default 0)
backoff?: "exponential" | "fixed"
baseDelayMs?: number // default 100
maxDelayMs?: number // default 30000
}ActionDefinition (returned)
ts
interface ActionDefinition<TSchema = ZodTypeAny> {
$kind: "action"
name: string
description?: string
schema: TSchema
retry?: RetryPolicy
policy?: string | readonly string[]
emits?: readonly EventDefinition[]
handler?: HandlerDefinition // present if inline handler was passed
persona?: string
journeyStep?: string
capability?: string
slo?: ActionSlo
tags?: readonly string[]
}
// Extract the input type at the type level
type ActionInput<A> = A extends ActionDefinition<infer S> ? z.output<S> : neverTwo registration shapes
Inline (compact)
ts
const submitAnswer = defineAction({
name: "submissions.submit-answer",
schema: SubmitAnswerInput,
emits: [AnswerSubmittedEvent],
handler: async (input) => AnswerSubmitted({ ...input, submittedAt: now() }),
})
// Pass to defineModule({ actions: [submitAnswer] })
// createApp auto-registers the inline handler.Separated (for cross-module dispatch)
When module A dispatches an action whose handler lives in module B:
ts
// modules/submissions/grade-submission.action.ts — contract only
export const gradeSubmission = defineAction({
name: "submissions.grade-submission",
schema: GradeSubmissionInput,
emits: [SubmissionAutoGradedEvent],
})
// modules/submissions/grade-submission.handler.ts
import { defineHandler } from "@nwire/forge"
import { gradeSubmission } from "./grade-submission.action"
export const gradeSubmissionHandler = defineHandler(gradeSubmission, async (input) =>
SubmissionAutoGraded({ ... }),
)
// In the module:
defineModule("submissions", {
actions: [gradeSubmission, submitAnswer, ...],
handlers: [gradeSubmissionHandler, ...],
})Cross-module callers import gradeSubmission (just the contract) and dispatch via ctx.request(gradeSubmission, ...). They never see the handler.
Studio-aware metadata effects
| Field | What Studio does |
|---|---|
persona | Groups in EventStorm; lights up Avi's full journey when filtered |
journeyStep | Drives L2 (Process Flow) grid columns |
capability | Filter in Actions list |
slo | Score observed latency / success rate against the target |
tags | Free-form filtering |
emits | Draws Command → Event edges on the canvas |
These are intent declarations — none affect runtime behavior. The runtime scores observed reality from telemetry against them.
Retry behavior
ts
defineAction({
name: "stripe.create-customer",
retry: { max: 3, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 5000 },
// ...
})The retry loop is inside runtime.dispatch. It:
- Tries the handler
- On throw, emits
action.failedtelemetry (per attempt) + waits backoff - Up to
max + 1total attempts - After exhausted, records to DLQ + emits
dlq.recorded - Re-raises to the caller
Middlewares run OUTSIDE the retry loop — one pass per dispatch.
Idempotency
The retry loop expects your handler to be idempotent. For non-idempotent side effects, push the side effect into a defineExternalCall with an idempotencyKey — the runtime threads the key through every retry.
What the handler can do
ts
defineHandler(submitAnswer, async (input, ctx) => {
// Envelope (auto-threaded from the wire / parent action)
ctx.envelope.tenant // tenant id
ctx.envelope.userId // authenticated user id
ctx.envelope.correlationId // chain root
ctx.requestId // shortcut for envelope.messageId
// Logging — envelope ids auto-attached
ctx.logger.info("submitting", { exercise: input.exerciseId })
// 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, { ... })
// Fire-and-forget
await ctx.send(notifyStudent, { ... })
// Load + use an actor view
const submission = await ctx.use(Submission, input.submissionId)
if (!submission.canBeFlagged()) throw new Error("invariant")
return submission.flag(input.reason) // returns event
// External boundary
const intent = await ctx.externalCall(chargeStripe, { ... })
// Resolve a DI dep
const mailer = ctx.resolve<Mailer>("mailer")
return AnswerSubmitted({ ... }) // event the actor folds
})What the handler MUST NOT do
WARNING
- ❌ Touch actor state directly — return an event; the actor's
assignfolds it - ❌ Throw on input validation that zod should catch
- ❌ Bypass the envelope — every nested call goes through
ctx.*
HandlerReturn
ts
type HandlerReturn = void | EventMessage | EventMessage[] | null- Return
void/null/undefinedif the handler did pure side effects with no event - Return one
EventMessagefor the common case - Return
EventMessage[]for multi-event handlers (rare; usually a smell — split the action)
See also
- Concepts → Action — the why
- defineHandler — the separated handler form
- defineEvent — what handlers return
- when() — reactions that dispatch actions