Skip to content

Projections (app.projection)

A Projection is a pure, typed, derived, read-only view of durable runtime truth for a specific consumer or channel.

text
Durable Facts (Event Log)


    Runtime State


   Runtime Snapshot


      Projection
   (app.projection)

   ┌──────┼─────────┬─────────┬─────────┐
   ▼      ▼         ▼         ▼         ▼
 Voice    UI      Agent      3D        MCP
Gemini   SSE    Context    WebGL     Cursor

The Core Hierarchy:

  • Events describe what happened.
  • State describes what is true.
  • Projections describe how that truth is experienced.

"The runtime owns truth. Projections decide how that truth is experienced."


Derived Views: "Persist Facts. Derive Representations."

Invariant strictly separates durable business truth from consumer representation:

"Durable state is the source. A derived view is a read-only interpretation of that source for a particular purpose."

Just like a computed property in reactive UI frameworks, a derived view does not create new state, does not mutate existing data, and does not execute side effects. It simply reformulates what is already true so an external system can consume it:

text
                DURABLE TRUTH (Facts)

          ┌──────────────┴──────────────┐
          │                             │
          ▼                             ▼
 Wait Presentation                App Projection
   (Node-Scoped)                (Consumer-Scoped)
          │                             │
          ▼                             ▼
 Interaction Meaning            Channel Experience
(Headless boundary facts)     (React / Voice / 3D / MCP)

The Three Questions

Every layer in Invariant answers a distinct question:

LayerQuestion AnsweredScopeExample
Durable StateWhat is true?Database / Event Log{ selectedDate: "2026-08-20", availableSlots: [...] }
Wait PresentationWhat does this boundary mean?Node-Scoped Boundary{ kind: "time-selection", slots: [...] }
App ProjectionHow should this consumer experience that truth?Channel / ConsumerVoice audio prompt, React Calendar, Three.js 3D Altar

Concrete Example: Derived Views All The Way Down

Consider a booking workflow paused at a time slot selection boundary:

ts
// 1. Headless Boundary Description (Node-Scoped)
workflow.wait<{ timeSlotId: string }>("select-time", {
  schema: z.object({
    timeSlotId: z.string(),
  }),
  presentation: ({ state }) => ({
    kind: "time-selection",
    title: "Choose an appointment time",
    slots: state.availableSlots.map((slot) => ({
      id: slot.id,
      startsAt: slot.startsAt,
      staffName: slot.staffName,
    })),
  }),
});

// 2. Channel-Specific Voice Projection (Audio Elicitation)
const voiceProjection = app.projection("booking-voice", ({ session }) => ({
  prompt: "Which time works best for you?",
  options: session.awaitedInput?.presentation?.slots?.map((slot: any) => ({
    spokenLabel: `${formatForSpeech(slot.startsAt)} with ${slot.staffName}`,
    value: slot.id,
  })) ?? [],
}));

// 3. Channel-Specific UI Projection (React Widget)
const widgetProjection = app.projection("booking-widget", ({ session }) => ({
  component: "TimeSlotPicker",
  slots: session.awaitedInput?.presentation?.slots,
}));

The Golden Rule: Persist Facts, Derive Representations

WARNING

Do not persist presentation artifacts as business state unless they are themselves business facts.

  • In 3D Graphics: drawnCard = "The Tower" is a durable fact. Screen coordinates $(x = 1.42, y = 0.85)$, Euler angles, camera zoom, and glow intensity are derived representations computed purely by tarot3DProjection(snapshot).
  • In Voice: appointmentDate = "2026-08-20T10:00:00Z" is a durable fact. "Tuesday at ten in the morning" is a derived representation computed purely by voiceProjection(snapshot).

If the same snapshot produces the same view, there is never a need to pollute your durable database event log with UI ephemeral artifacts.


The Four Golden Properties

PropertyRuleWhy It Matters
1. Synchronous & Side-Effect Free(ctx) => TOutputProjections are required to be synchronous and side-effect free. Disallowing Promise / async structurally excludes standard async I/O patterns.
2. Deterministic & Derived$\text{Snapshot} \rightarrow \text{View}$"Given the same snapshot, a projection should produce the same view." Pure functional transformation over durable truth.
3. TypedFully inferredTypeScript infers Projection<TOutput> with zero manual typecasts or any.
4. Read-OnlyZero AuthorityProjections cannot execute actions, advance workflows, or modify session context.

IMPORTANT

"Channel Projectors represent truth for a specific transport or medium, but never define execution authority or tool schemas."
A Voice Channel Projector shapes acoustics, phonetics, and natural speech (spokenPrompt, spokenOptions). The Agent (app.agent) defines semantic authority and dynamic action contracts.

NOTE

Semantic Contract: Requiring a synchronous signature (ctx) => TOutput prevents accidental async network calls or database fetches, establishing a clear expectation of pure computation. External effects and data fetching belong strictly in Hydration and Capabilities.


RuntimeProjection vs. ApplicationProjection

Invariant formalizes two distinct projection layers:

text
┌─────────────────────────────────────────────────────────────┐
│                      Durable Truth                          │
│  (Session Context + Execution State + Active Wait Node)     │
└──────────────────────────────┬──────────────────────────────┘

               ┌───────────────┴───────────────┐
               ▼                               ▼
     RuntimeProjection                ApplicationProjection
  (Generated by Invariant)            (Defined by Developer)
  ────────────────────────            ────────────────────────
  - activeWorkflow                    - Spoken prompts (Voice)
  - currentNodeId                     - UI Form fields (Widget)
  - validActions (Authority)          - Spatial 3D layouts (Tarot)
  - expectedInput (Schema)            - Filtered client views
  - revision & status                 - Agent reasoning context
  • RuntimeProjection: Invariant automatically computes execution metadata (validActions, expectedInput, revision, and activeExecution.currentNodeId). Wait presentation remains available through session.awaitedInput.
  • ApplicationProjection: The developer defines how that metadata is translated for a specific consumer.

Defining Projections (app.projection)

Projections are declared using app.projection(name, projectFn):

ts
import { app } from "@invariant/sdk";

// 1. Voice Channel Projection (for Gemini Live)
export const bookingVoiceProjection = app.projection(
  "booking-voice",
  ({ session, runtime }) => ({
    customerName: session.context.clientInfo?.firstName ?? "there",
    spokenPrompt: runtime.expectedInput
      ? "Which time slot works best for your appointment?"
      : `Welcome! How can I assist you today?`,
    tools: toVoiceTools(runtime.validActions),
    language: session.context.preferredLanguage ?? "en",
  })
);

// 2. 3D Spatial Scene Projection (for Three.js WebGL)
export const tarot3DProjection = app.projection(
  "tarot-3d",
  ({ runtime, session }) => ({
    cards: deriveSpatialCards(runtime.activeExecution?.data?.drawnCards),
    camera: runtime.activeExecution?.status === "waiting_input"
      ? { position: [0, 4.5, 6.0], target: [0, 0, 0] }
      : { position: [0, 8.0, 10.0], target: [0, 0, 0] },
    altarGlow: session.context.theme === "dark" ? "#7c4dff" : "#ffd700",
  })
);

Evaluating Projections

1. Synchronous Direct Evaluation (projection.get())

For one-off REST API endpoints, RPCs, or testing:

ts
// Evaluates pure view from current durable state
const view = bookingVoiceProjection.get(session);
// Or using session helper:
const view = session.project(bookingVoiceProjection);

2. Reactive Streaming (session.subscribe())

Invariant strictly separates projection definition from streaming transport:

text
Projection              Session / Runtime Stream              SSE / WebSocket
──────────              ────────────────────────              ───────────────
How to derive view  ──► When durable truth changed       ──► How view reaches client

Consume projections as asynchronous iterables:

ts
// Fastify / Express SSE endpoint
app.get("/sessions/:id/stream", async (req, reply) => {
  const session = app.sessions.getSession(req.params.id);
  if (!session) return reply.status(404).send({ error: "Session not found" });

  reply.raw.setHeader("Content-Type", "text/event-stream");
  reply.raw.setHeader("Cache-Control", "no-cache");

  for await (const view of session.subscribe(bookingWidgetProjection)) {
    reply.raw.write(`data: ${JSON.stringify(view)}\n\n`);
  }
});

Authority Boundary: Canonical Actions vs. Channel Tools

runtime.validActions is Canonical Authority.A Channel Tool is a Channel-Specific Representation.

ts
// Correct: Transform canonical validActions into speech declarations
function toVoiceTools(validActions: readonly { readonly name: string }[]) {
  return validActions.map((action) => ({
    name: action.name,
    spokenDescription: `Voice command for ${action.name}`,
    parameters: { type: "object" },
  }));
}

A Voice projection or MCP projection never invents actions out of thin air. It formats the runtime's authoritative validActions into the representation expected by that channel's LLM or frontend.


Unifying Agent Context with Projections

An Agent's reasoning context is simply a specialized Projection:

ts
const supportContextProjection = app.projection(
  "support-context",
  ({ session, runtime }) => ({
    customerTier: session.context.accountTier,
    activeWorkflow: runtime.activeExecution?.workflowId,
    expectedInput: runtime.expectedInput?.schema,
  })
);

export const supportAgent = app.agent("support", {
  instructions: "Help customers using only the registered booking workflow.",
  projection: supportContextProjection,
  workflows: [bookingWorkflow],
});

What a Projection is NOT

To keep your architecture clean, remember that a Projection is:

  • NOT State: Projections hold no state; state lives in the Event Log.
  • NOT Memory: Projections do not remember past turns; they transform the current snapshot.
  • NOT a Capability: Projections do not perform I/O, execute HTTP requests, or charge cards.
  • NOT an Agent: Projections do not reason or make decisions.
  • NOT an API Endpoint: Projections define the transformation; your web framework handles HTTP/SSE transport.

"Projection sits between truth and interpretation."


Go Deeper

  • Mental Model — See where Projections fit in Invariant's public architecture.
  • Agents & Actions — Learn how Agent Context Projections guide LLM turns.
  • SDK Reference — Full TypeScript API reference for app.projection().

Invariant Durable Execution Engine.