Five-minute tour
Every primitive in one read.
Actions: the verbs
const submitAnswer = defineAction({
name: "submissions.submit-answer",
persona: "Avi (9, beginner)",
journeyStep: "J3-submit",
slo: { p95LatencyMs: 200, successRate: 0.999 },
schema: z.object({ studentId: z.string(), answer: z.string() }),
emits: [AnswerWasSubmitted],
handler: async (input) => AnswerSubmitted(input),
});Actions carry intent metadata (persona, journeyStep, slo, emits) that Studio reads to render persona journeys + SLO scorecards.
Events: the facts
Past-tense exported symbol — <Subject>Was<VerbPast>:
const AnswerWasSubmitted = defineEvent({
name: "submissions.answer-was-submitted",
outcome: "milestone", // success | failure | warning | milestone
audience: ["product", "ops"],
schema: z.object({ studentId: z.string(), answer: z.string() }),
});Events are immutable past-tense records. outcome lets Studio aggregate without parsing names.
Schemas: the data + lifecycle
const SubmissionData = defineSchema({
name: "submission",
key: "studentId",
fields: { studentId: z.string(), verdict: z.string().optional() },
states: {
submitted: { initial: true },
"under-review": {},
graded: { final: true },
},
storage: { indexes: [["studentId"]] },
});Declared once, referenced by the actor — and inspectable by Studio without booting the actor.
Actors: the state machines
const Submission = defineActor({
schema: SubmissionData,
states: {
submitted: {
on: { [AnswerWasSubmitted.name]: { assign: (_s, e) => e } },
},
"under-review": {
on: { [SubmissionWasGraded.name]: { target: "graded", assign: (_s, e) => e } },
after: { reminder: { delay: "3d", action: "submissions.send-reminder" } },
},
// `graded` is final in the schema — omit it.
},
stuckThresholds: { "under-review": 48 * 60 * 60 * 1000 },
slas: { "under-review": { maxDurationMs: 7 * 24 * 60 * 60 * 1000, escalateTo: "lead" } },
});Actors decide their own state transitions. Handlers never write to actors — they return events; actors react.
Projections: the read views
const SubmissionsByStudent = defineProjection<{ items: Submission[] }>(
"submissions-by-student",
{
listens: [AnswerWasSubmitted, SubmissionWasGraded],
initial: () => ({ items: [] }),
on: {
[AnswerWasSubmitted.name]: (state, event) => ({
items: [...state.items, event],
}),
},
},
);Projections fold events into queryable state. They're pure — no side effects.
Queries: the read functions
const submissionsByStudent = defineQuery(SubmissionsByStudent, {
name: "submissions.by-student",
schema: z.object({ studentId: z.string() }),
execute: (state, { studentId }) => state.items.filter((s) => s.studentId === studentId),
});Queries read projections. Never actors.
Reactions: the policies
const autoGrade = when(
AnswerWasSubmitted,
async (event, ctx) => {
const confidence = mockGrade(event.answer);
if (confidence > 0.9) {
await ctx.request(gradeSubmission, { studentId: event.studentId, verdict: "pass" });
} else {
await ctx.request(flagForReview, { studentId: event.studentId });
}
},
{ dispatches: [gradeSubmission, flagForReview] },
);when(Event, fn, { dispatches }) declares the causal chain Studio draws as Policy → Command edges. The dispatches array is intent; the runtime captures observed reality in telemetry.
External boundaries: orchestrator primitives
const chargeStripe = defineExternalCall({
name: "stripe.charge",
target: { provider: "stripe", endpoint: "/v1/payment_intents" },
request: z.object({ amount: z.number(), orderId: z.string() }),
response: z.object({ id: z.string(), status: z.string() }),
idempotencyKey: (r) => `charge-${r.orderId}-${r.amount}`,
slo: { p95LatencyMs: 600, successRate: 0.995 },
retry: { max: 3, backoff: "exponential" },
});
// inside a handler / reaction:
const result = await ctx.externalCall(chargeStripe, { amount: 4200, orderId: "..." });Also: defineInboundWebhook (HTTP callbacks), defineOutbox (transactional), defineInbox (dedup), defineCron (schedules).
Modules: bounded contexts
const submissions = defineModule("submissions", {
description: "Submission lifecycle: receive → grade → review.",
owners: ["curriculum-eng"],
journey: [
{ id: "J3-submit", label: "Submit an answer" },
{ id: "J5-grade", label: "System grades a submission" },
],
actions: [submitAnswer, gradeSubmission, flagForReview],
actors: [Submission],
events: [AnswerWasSubmitted, SubmissionWasGraded],
projections: [SubmissionsByStudent],
queries: [submissionsByStudent],
reactions: [autoGrade],
externalCalls: [chargeStripe],
});Apps + Topology: deployment is data
const learnflowApp = defineApp("learnflow", {
modules: [submissions, enrollments, mastery, lessons],
tenantModel: "per-org",
tenantKey: "schoolId",
});
// One topology manifest, multiple deployment shapes:
const manifest: TopologyManifest = {
apps: "all",
topology: "monolith", // or "split"
providers: { bus: "in-memory" },
transport: { http: { port: 3000, inspect: true } },
};
await composeTopology(manifest, registry).then((t) => t.start());Switch to topology: "split" and the same modules run as N separate processes. Or change providers.bus to "nats" and it talks across a real cluster. No code change.
Telemetry: the substrate
app.runtime.onTelemetry((rec) => {
// 13 lifecycle kinds + 9 orchestrator kinds:
// action.dispatched/completed/failed
// event.published
// actor.transitioned
// projection.folded
// reaction.fired/failed
// query.executed
// timer.scheduled/fired
// dlq.recorded
// external.call.started/completed/failed
// inbound.webhook.received
// outbox.flushed | inbox.dedup.hit
// queue.job.enqueued/started/completed
// cron.fired
});Studio subscribes to this stream. @nwire/telemetry-otel maps it to OTLP for Datadog / Honeycomb / Vector + GreptimeDB.
Studio: the visual companion
pnpm dlx @nwire/studio- Live — real-time event stream + correlation trace tree
- Dispatch — fire any action via form built from its zod schema
- EventStorm — three reading levels (Big Picture / Process Flow / Software Design) with Play Trace that replays recent telemetry across the canvas in causal order
- Run — spawns + supervises wire processes; stdout streams in the UI
- Topology / Modules / Actions / Events / Routes — static structural views from the scan cache
Where to go next
- Concepts → Action — the deep dive on each primitive
- Guides → Testing —
@nwire/test-kitharness + BDD scenarios - Recipes — RealWorld, service-template, NestJS samples migrated to Nwire