Skip to content

Five-minute tour

Every primitive in one read.

Actions: the verbs

ts
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>:

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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

bash
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

MIT licensed.