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 CursorThe 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:
| Layer | Question Answered | Scope | Example |
|---|---|---|---|
| Durable State | What is true? | Database / Event Log | { selectedDate: "2026-08-20", availableSlots: [...] } |
| Wait Presentation | What does this boundary mean? | Node-Scoped Boundary | { kind: "time-selection", slots: [...] } |
| App Projection | How should this consumer experience that truth? | Channel / Consumer | Voice 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 bytarot3DProjection(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 byvoiceProjection(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
| Property | Rule | Why It Matters |
|---|---|---|
| 1. Synchronous & Side-Effect Free | (ctx) => TOutput | Projections 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. Typed | Fully inferred | TypeScript infers Projection<TOutput> with zero manual typecasts or any. |
| 4. Read-Only | Zero Authority | Projections 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 contextRuntimeProjection: Invariant automatically computes execution metadata (validActions,expectedInput,revision, andactiveExecution.currentNodeId). Wait presentation remains available throughsession.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 clientConsume 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.validActionsis 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().