defineAction
defineAction({ ... }) declares a typed command. The verb of your domain.
Signature
import { defineAction } from "@nwire/forge"
function defineAction<TSchema extends ZodTypeAny>(
meta: ActionMeta<TSchema>,
): ActionDefinition<TSchema>ActionMeta
interface ActionMeta<TSchema extends ZodTypeAny> {
// Required
name: string // routing key — unique across the app
input: 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 — the common case where contract + handler live together.
// Omit it for a schema-only contract you dispatch toward (cross-App).
handler?: (ctx: HandlerContext & { input: z.output<TSchema> }) => 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)
interface ActionDefinition<TSchema = ZodTypeAny> {
$kind: "action"
name: string
description?: string
input: TSchema
retry?: RetryPolicy
policy?: string | readonly string[]
emits?: readonly EventDefinition[]
config?: unknown // kernel handler config — 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)
const submitAnswer = defineAction({
name: "submissions.submit-answer",
input: SubmitAnswerInput,
emits: [AnswerSubmittedEvent],
handler: async ({ input }) => AnswerSubmitted({ ...input, submittedAt: now() }),
})
// Registered on the App through its registry (createApp({ registry }))Schema-only contract (for cross-App dispatch)
An action IS a handler. When one App dispatches an action whose handler lives in another, the calling side imports a contract — a defineAction with no handler — and dispatches toward it. The owning App declares the same action with its handler and registers it.
// packages/submissions-contract/grade-submission.action.ts — contract only
export const gradeSubmission = defineAction({
name: "submissions.grade-submission",
input: GradeSubmissionInput,
emits: [SubmissionAutoGradedEvent],
})
// packages/submissions/grade-submission.action.ts — same name, with handler
export const gradeSubmission = defineAction({
name: "submissions.grade-submission",
input: GradeSubmissionInput,
emits: [SubmissionAutoGradedEvent],
handler: async ({ input }) => SubmissionAutoGraded({ ... }),
})
// The owning app registers it through its registry; an action IS a handler:
createApp({
appName: "submissions",
registry: defineRegistry({ handlers: [gradeSubmission, submitAnswer] }),
})Cross-App callers import the contract gradeSubmission and dispatch via ctx.execute(gradeSubmission, ...). They never see the handler body.
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
defineAction({
name: "stripe.create-customer",
retry: { max: 3, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 5000 },
// ...
})The retry loop is inside the forge action pipeline (reached by runtime.execute). 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
defineAction({
name: "submissions.submit-answer",
input: SubmitAnswerInput,
handler: async (ctx) => {
const { 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.execute(scoreSubmission, { ... })
// Fire-and-forget
await ctx.send(notifyStudent, { ... })
// Load + use an actor view
const submission = await ctx.actor(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
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
- defineEvent — what handlers return
- defineWorkflow —
whenreactions that dispatch actions