TypeScript SDK & API Reference
Authoritative TypeScript API reference for @invariant-tech/sdk primitives, workflow graph builders, control-flow operators, agent decision layer, session management, and runtime contracts.
1. Application Setup (invariant())
The invariant() function creates the root application container configured with storage adapters, model registries, and Session hydration.
ts
import { invariant } from "@invariant-tech/sdk";
import { sqlite } from "@invariant-tech/sqlite";
import { openai, OPENAI_MODELS } from "@invariant-tech/openai";
export const app = invariant({
storage: sqlite("./data/invariant.db"),
models: {
default: openai({ defaultModel: OPENAI_MODELS.GPT_4O }),
fast: openai({ defaultModel: OPENAI_MODELS.GPT_4O_MINI }),
},
session: {
hydrate: async ({ userId, sessionId }) => {
const customer = await crm.getCustomer(userId);
return { customerId: customer.id, accountTier: customer.tier };
},
},
});Application Configuration & Entry Points (InvariantAppConfig)
| Property / Method | Type | Description |
|---|---|---|
storage / store | RuntimeStore | Persistence adapter (@invariant-tech/sqlite, @invariant-tech/postgres, or custom store). |
models | ModelRegistry | Configured model registry mapping keys (default, fast) to RuntimeModelAdapter instances. |
session | SessionConfig<TSessionContext> | Configuration for the hydrate callback. contextSchema is accepted but reserved and is not runtime validation. |
observability | ObservabilityConfig | Diagnostic sinks (consoleTraceSink) and trace capture configuration. |
debug | boolean | { level?: TraceLogLevel } | Toggles automatic consoleTraceSink rendering for debugging. |
app.workflow(id, opts) | WorkflowBuilder | Declares a durable, type-safe workflow graph builder. |
app.fragment(name) | WorkflowFragment | Creates a reusable subgraph fragment. |
app.projection(id, fn) | Projection | Declares a pure, typed application projection. |
app.agent(name, opts) | AppAgent | Declares a semantic agent operating over registered workflows. |
app.sessions | SessionManager | Creates process-local Sessions or asynchronously loads durable Sessions. |
app.startWorkflow(wf, input) | Promise<{ runId, status }> | Executes a workflow instance without a caller-managed Session. Intended for non-interactive jobs. |
2. Workflows & Fragments
app.workflow()
Creates a compiled, type-safe workflow graph builder with an optional input schema and lifecycle compensation.
ts
import { z } from "zod";
export const refundWorkflow = app.workflow("customer-refund", {
description: "Processes customer refund requests with policy evaluation.",
inputSchema: z.object({
orderId: z.string(),
reason: z.string(),
}),
lifecycle: {
cancel: cancelRefundFragment, // Durable compensation fragment
},
});| Option | Type | Description |
|---|---|---|
description | string | Natural language description used for Agent intent routing. |
version | string | Optional workflow semantic version (defaults to "1.0.0"). |
inputSchema / schema | AuthoredSchema<TInput> | Zod schema or JSON Schema object validating initial workflow input. Parameterless workflows default to { additionalProperties: false }. |
lifecycle | WorkflowLifecycleOptions | Optional lifecycle configuration (cancel?: Fragment). |
Plain-object schema dialect
Plain schema objects use Invariant Runtime Schema v1, identified by the optional $schema: "urn:invariant:schema:runtime:v1". It is a deliberately limited dialect, not an implementation of JSON Schema Draft 7 or 2020-12. Schema compilation fails with SchemaDialectError (UNSUPPORTED_SCHEMA_DIALECT) when it encounters an unknown keyword, an unsupported standard-draft identifier, a malformed constraint, or a non-JSON/cyclic value. No keyword is silently ignored.
The validation keywords are type (one scalar type), enum (an empty array rejects every value), const, properties, required, boolean additionalProperties, one-schema items, minLength, maxLength, pattern (JavaScript regular expression), minimum, and maximum. The annotation-only keywords are title, description, default, examples, deprecated, readOnly, and writeOnly. Combinators, references, format, tuple schemas, type arrays, schema-valued additionalProperties, and all other JSON Schema keywords are unsupported in this Beta.
Zod-like inputs remain authoritative through their own safeParse/safeParseAsync implementation. A custom RuntimeSchema remains authoritative through its own validate method. Their provider-facing jsonSchema representation guides model generation, but the runtime parser—not the provider representation—decides whether input is accepted.
ts
import {
INVARIANT_SCHEMA_DIALECT_ID,
createRuntimeSchema,
} from "@invariant-tech/sdk";
const input = createRuntimeSchema({
$schema: INVARIANT_SCHEMA_DIALECT_ID,
type: "object",
properties: {
workflowId: { type: "string", const: "booking" },
},
required: ["workflowId"],
additionalProperties: false,
});app.fragment()
Creates a reusable, composable subgraph fragment. Fragments support all workflow primitives including .step(), .capability(), .wait(), .reason(), .repeat(), and .branch():
ts
const issueRefundFragment = app.fragment("issue-refund-subgraph")
.capability("issue-stripe-refund", issueStripeRefund)
.step("build-receipt", ({ state }) => ({ receiptId: `rcpt_${Date.now()}` }))
.capability("send-receipt-email", sendReceiptEmail);Fragments can also define nested durable loops via .repeat():
ts
const searchLoopFragment = app.fragment("search-loop-fragment")
.repeat("search-iterations", {
maxIterations: 10,
do: app.fragment("search-step").wait("search-ui", { schema: SearchInputSchema }),
});Automatic Subgraph Namespacing: During compilation,
app.fragment()automatically flattens subgraphs—including nested.repeat()loops and.branch()decision trees—and namespaces inner node IDs (e.g.service-path.grooming.haircut-length-ui) to guarantee global node ID uniqueness across execution traces and event logs.
3. Workflow Graph Nodes
.step()
Defines a pure, deterministic, in-process computation node. Output properties merge into the accumulated state.
ts
workflow.step("check-policy", ({ input, state }) => ({
eligible: Boolean(state.customer?.active && state.customer?.refundWindowOpen),
}));| Parameter | Type | Description |
|---|---|---|
nodeId | string | Unique identifier for this step within the workflow graph. |
handler | StepHandler<TInput, TState, TOutput> | Synchronous or asynchronous computation function ({ input, state }) => Promise<TOutput> | TOutput. |
Steps perform deterministic in-process computation without external side-effects.
.reason()
Delegates a bounded semantic decision task to a configured model adapter inside an explicit schema boundary.
ts
workflow.reason("classify-intent", {
instruction: "Classify the support request in execution context into a category and urgency tier.",
schema: {
type: "object",
properties: {
category: { type: "string", enum: ["REFUND", "DELIVERY", "ACCOUNT", "OTHER"] },
urgency: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] },
},
required: ["category", "urgency"],
},
model: "fast", // Selects registered model key from app.models
fallback: "fallback-classification",
});| Option | Type | Description |
|---|---|---|
instruction | string | Explicit instruction directing the LLM for this bounded task. |
schema | Record<string, unknown> | Expected output JSON schema enforced via structured outputs. |
model | string | Optional model registry key (e.g. "fast", "default") declared in app.models. |
fallback | string | Optional node ID to transition to if reasoning fails. |
Per-node model routing
Yes: every .reason() node can select a different registered adapter, including adapters from different providers. The model value is an application registry key—not a provider model ID. The selected adapter owns its defaultModel provider identifier.
ts
import { invariant } from "@invariant-tech/sdk";
import { openai, OPENAI_MODELS } from "@invariant-tech/openai";
import { anthropic, ANTHROPIC_MODELS } from "@invariant-tech/anthropic";
const app = invariant({
models: {
fast: openai({ defaultModel: OPENAI_MODELS.GPT_5_6_LUNA }),
deep: anthropic({ defaultModel: ANTHROPIC_MODELS.CLAUDE_SONNET_5 }),
},
});
export const reviewWorkflow = app
.workflow("multi-model-review")
.reason<{ category: "BILLING" | "OTHER" }>("classify", {
model: "fast",
instruction: "Classify the request in execution context.",
schema: {
type: "object",
properties: { category: { type: "string", enum: ["BILLING", "OTHER"] } },
required: ["category"],
additionalProperties: false,
},
})
.reason<{ approved: boolean }>("review", {
model: "deep",
instruction: "Review the classification and decide whether it is approved.",
schema: {
type: "object",
properties: { approved: { type: "boolean" } },
required: ["approved"],
additionalProperties: false,
},
});If model is omitted, the node uses the default registry entry. If an explicit key is not registered, the attempt becomes REASON_FAILED; Invariant does not silently substitute default. Model selection is deterministic graph configuration and replay consumes the committed result without calling either model again.
prompt, context, and projection remain authoring-reserved fields but are not evaluated by the current Beta Session host. Do not rely on them for data minimization or dynamic prompting until an explicit reasoning-boundary policy is released.
.capability()
Defines an external side-effect boundary (API call, database mutation, email dispatch, queue delivery). Capabilities execute pure I/O and are backed by the Invariant Transactional Outbox.
ts
workflow.capability("issue-stripe-refund", {
idempotencyKey: "refund:{{runId}}",
handler: async ({ state, input }) => {
return await stripe.refunds.create({
charge: state.chargeId,
amount: state.refundAmount,
});
},
fallback: "notify-ops-failure",
});| Option | Type | Description |
|---|---|---|
handler | CapabilityHandler<TInput, TState, TOutput> | Async capability execution function async ({ state, input }) => Promise<TOutput>. |
idempotencyKey | string | Stable template. and are resolved by the kernel. |
fallback | string | Optional node ID to transition to if capability execution fails. |
.wait()
Suspends workflow execution durably until matching external input arrives.
ts
import { z } from "zod";
workflow.wait("await-approval", {
schema: z.object({
approved: z.boolean(),
approverId: z.string(),
}),
presentation: ({ state }) => ({
prompt: "Please review and approve this refund request.",
choices: [
{ label: "Approve Refund", value: true },
{ label: "Reject Refund", value: false },
],
}),
});| Option | Type | Description |
|---|---|---|
schema | AuthoredSchema | Schema (Zod or JSON Schema) validating incoming user input at re-entry. |
presentation | object | (({ state, session }) => object) | Optional UI/Voice presentation metadata (prompt, choices, form layout). |
Automatic timer scheduling is not part of the current Beta host. Keep time-based escalation in application scheduling code until a timer worker is released.
4. Control-Flow & Composition Operators
.branch()
Deterministically routes execution between subgraph branches based on a selector function inspecting input and state:
ts
workflow.branch(
"route-decision",
({ input, state }) => (input.locationId ? "PROVIDED" : "PROMPT"),
{
PROVIDED: app.fragment("use-location").step("set-loc", ({ input }) => ({ locationId: input.locationId })),
PROMPT: app.fragment("ask-loc").wait("select-loc-ui", { schema: SelectLocSchema }),
}
);The selector is required and is the sole decision source. It must return one key present in the branch map. The runtime never guesses from arbitrary state values or conventional property names. Static selector/target omissions fail preflight before durable mutation; a thrown selector or unmatched runtime key terminates with an explicit WORKFLOW_FAILED error.
An empty branch fragment is represented explicitly as a terminal branch and completes that path. It does not fall through. Use a non-empty fragment, such as .step("continue", () => ({})), when the branch should join the downstream workflow without changing application state.
.repeat()
Iterates a subgraph fragment durably for bounded loops, polling routines, or pagination:
ts
workflow.repeat("automation-loop", {
while: ({ state }) => state.customer.active,
maxIterations: 50,
do: app.fragment("loop-step")
.capability("check-status", checkStatus)
.step("evaluate-progress", ({ state }) => ({ done: state.status === "READY" })),
});| Option | Type | Required | Semantics |
|---|---|---|---|
maxIterations | number | Yes | Positive-integer hard cap. Preflight rejects missing, zero, fractional, or negative values. |
while | ({ state }) => boolean | No | Evaluated against committed state before each body entry. false exits and can produce zero iterations. |
do | Fragment | Yes | Namespaced loop body. Its tail returns to the repeat node unless .break() exits it. |
The hard cap is checked before while. A predicate exception or non-boolean result fails the workflow durably; it is not silently ignored. The runtime persists iteration counts in loopState, separate from projected application state.
.child()
Spawns a separate child workflow execution with its own runId and event history:
ts
workflow.child("fraud-check-child", fraudReviewWorkflow, {
input: ({ state }) => ({ userId: state.customerId, amount: state.refundAmount }),
});Roadmap Note:
.child()is a planned v2.2 composition operator. In the current Beta release, child workflows are rejected during preflight validation (UnsupportedNodeError).
5. Direct Workflow Execution
Workflows can be started directly by application code through either a Session or the InvariantApp container:
ts
// 1. Session-Scoped Execution (Preserves user context & projection feed)
const session = app.sessions.createSession("usr_9981");
const run = await session.startWorkflow(refundWorkflow, {
orderId: "ord_99",
reason: "Defective item",
});
console.log(`Workflow started: ${run.runId}`);
// 2. Read Authoritative Session Snapshot
const snapshot = session.snapshot();
console.log("Projected State:", snapshot.projectedState);
// 3. Resume Suspended .wait() Node with Validated Input
await session.submitInput({
approved: true,
approverId: "mgr_44",
});
// 4. Cancel Active Workflow Execution
await session.cancelWorkflow();
// 5. Standalone non-interactive execution (cron jobs, background tasks, webhooks)
const standaloneRun = await app.startWorkflow(refundWorkflow, {
orderId: "ord_100",
reason: "Duplicate charge",
});session.startWorkflow() drains synchronous consequences before resolving, but { status: "started" } is an admission receipt, not the final execution snapshot. The following session.snapshot() is the low-level way to inspect its current boundary. Agent actions use the stronger settled contract described below.
6. Session Management (app.sessions & Session)
A Session is the continuity boundary for identity, hydrated context, channels, and related workflow executions. Use the async loader with a durable configured store when context and a paused registered execution must survive process restarts.
ts
// Create a new durable Session with a Host-generated UUID:
const session = await app.sessions.createDurableSession("usr_441", {
preferredLanguage: "en", // optional initialContext seed defaults
});
// Read session context:
console.log(session.context.customerId);
// Opaque stream cursor for projection feeds:
console.log(session.streamCursor);
// Evaluate an Application Projection synchronously:
const view = session.project(bookingVoiceProjection);
// Subscribe to reactive projection streaming:
const unsubscribe = session.subscribeProjection((feedEvent) => {
console.log("Projection changed:", feedEvent.snapshot);
});Session Methods
| Method | Return Type | Description |
|---|---|---|
session.snapshot() | SessionSnapshot<TContext> | Returns a fresh read-only snapshot view of current context, active workflow, and awaited input. Nested application values are not deep-frozen. |
session.startWorkflow(wf, input) | Promise<{ runId, status: "started" }> | Admission receipt. The method drains synchronous work, but "started" is not the settled execution status. |
session.submitInput(payload) | Promise<{ submitted, activeRunId? }> | Resumes the active .wait() node after validating input against its schema. |
session.runExclusive(operation) | Promise<TResult> | Executes an async operation within the in-process session's serialization tail with mutual exclusion across host-local mutating actions (submitInput, startWorkflow, cancelWorkflow) and re-entrant lease safety. |
session.cancelWorkflow() | Promise<{ cancelled, status }> | Cancels the active execution, running compensation lifecycle fragments if declared. |
session.project(projection) | TOutput | Evaluates a projection against current session state synchronously. |
session.subscribe(projection) | AsyncGenerator<TOutput> | Async generator yielding fresh projection states on every change. |
session.subscribeProjection(listener) | () => void | Attaches a raw listener callback to projection feed events. Returns unsubscribe function. |
session.getProjectionHistory(afterCursor?) | readonly ProjectionFeedEvent[] | Retrieves historical projection events for SSE reconnections. |
In-process serialization vs. distributed durability
runExclusive() serializes host-local Session operations. Durable correctness across processes remains enforced by store OCC and execution ownership primitives.
Session context is not workflow input
session.context is long-lived application continuity data. session.startWorkflow(workflow, input) and the start_workflow Agent action pass only the explicit input object into the workflow. The SDK never merges Session context into workflow input. Expose selected context through an Agent projection or copy an explicitly authorized value into workflow input in application code.
getOrCreateSession() is synchronous and process-local. loadOrCreateSession() first calls RuntimeStore.loadSession(); only a missing Session invokes hydrate(), after which the context is persisted.
createSession(userId, initialContext?) and createDurableSession(userId, initialContext?) generate canonical UUID Session IDs. The explicit-ID loaders remain available for applications that already own a stable UUID. Reusing an ID with another userId throws SessionOwnershipError.
restoreSession(id, userId) returns an existing owned in-memory Session or reconstructs an owned stored one. Workflows authored through app.workflow() are registered automatically. If the Session row names an active_run_id and its workflow is registered, the restored snapshot exposes the same run, revisions, projected state, and .wait() contract. A missing bound execution or an unregistered non-terminal graph fails closed instead of being treated as idle. Restoration does not redispatch interrupted capabilities or implement retries/timers.
SessionSnapshot separates sessionRevision, contextRevision, and executionRevision. Context writes never fabricate execution-event progress. Starting a run advances Session authority when it changes activeRunId; execution revision advances only with durable runtime events. A direct second start while the current run is non-terminal throws ActiveSessionRunError.
SessionConfig.contextSchema is a reserved authoring field in the current Beta. The runtime does not evaluate it when accepting initial context, hydration output, stored context, updateContext() patches, or rehydrate() output. Use the TypeScript generic for static checking and validate untrusted data in application code. See Session Context Schema Status.
7. Projections (app.projection())
Declares a pure, typed, derived, read-only view of durable runtime truth for a specific consumer or channel (Voice, UI SSE, MCP, Mobile).
ts
export const bookingVoiceProjection = app.projection(
"booking-voice",
({ session, runtime, execution }) => ({
customerName: session.context.clientInfo?.firstName ?? "there",
spokenPrompt: runtime.expectedInput
? "Which date works best for your appointment?"
: "Welcome to Boulevard Salon. How can I help?",
validActions: runtime.validActions,
activeRunId: execution?.runId,
})
);Direct Evaluation & Streaming
ts
// 1. Direct evaluation from session or snapshot
const view = bookingVoiceProjection.get(session);
// 2. Async generator streaming
for await (const view of session.subscribe(bookingVoiceProjection)) {
sendSSE(view);
}8. Agents (app.agent() & Agent.run())
Agents interpret user intent and propose constrained actions over registered workflows.
ts
const supportProjection = app.projection("support-context", ({ session }) => ({
customerId: session.context.customerId,
tier: session.context.accountTier,
}));
export const supportAgent = app.agent("customer-support", {
description: "Customer support concierge for orders and refunds",
instructions: `
Help customers resolve order and refund inquiries.
Be polite, concise, and helpful.
`,
workflows: [refundWorkflow],
projection: supportProjection,
conversation: {
recentTurns: 8,
},
model: "fast", // Selects registered model key from app.models
rejectionRecovery: {
mode: "explain", // Bounded single-shot recovery for recoverable validation rejections
},
});Agent Turn Execution (agent.run())
ts
const result = await supportAgent.run({
session,
message: "I want a refund for order ord_99",
history: [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello! How can I assist you today?" },
],
});
if (result.outputDisposition === "present" && result.output !== undefined) {
console.log(result.output); // Exact model-authored text safe to present
}
console.log(result.modelOutputPhase); // 'initial' | 'rejection_recovery'
console.log(result.action); // Normalized model proposal (accepted or rejected)
console.log(result.actionResult); // Authoritative runtime decision
console.log(result.presentation); // Exact application-authored current boundary metadata
console.log(result.execution); // Settled consequence caused by the accepted action
console.log(result.projection); // Application projection from the same committed revision
console.log(result.actionSurface);// Authority from the same committed revision
console.log(result.boundaryView); // Single causal view used by every field aboveIf the selected adapter is missing, throws, or returns no usable text/action/proposal, agent.run() rejects with AgentModelError (code: "AGENT_MODEL_FAILED"). The turn emits llm.call.started followed by llm.call.failed; it does not manufacture a greeting, emit llm.call.completed, or return a successful AgentTurnResult.
Before the initial call, agent.run() captures the Session/run/revision/wait-boundary authority that produced the prompt and action surface. Official adapters return every provider tool proposal in ModelReasonResponse.toolCalls, preserving provider order. Zero proposals use the text path, exactly one may proceed, and more than one returns a rejected actionResult with reason: "AMBIGUOUS_MODEL_PROPOSAL"; no proposal executes. A single proposal whose captured frame is no longer current rejects with reason: "STALE_REASONING_FRAME" before fresh action authorization.
AgentTurnResult keeps authorship and authority separate:
| Field | Meaning |
|---|---|
output? | Exact model/projector text safe for the application to present. The SDK never writes acknowledgement, success, failure, language, or tone copy. |
modelOutput? | Exact text returned by the model, including pre-settlement text withheld because it accompanied an action proposal, or post-rejection clarification text. |
modelOutputPhase? | Provenance of the returned modelOutput: "initial" or "rejection_recovery". |
outputSource? | Provenance of presentable output: "model" or "projector". Current agent.run() model text uses "model". |
outputDisposition | "present", "withhold", or "none". Check this before recording an assistant message. |
action? | Normalized action proposed by the model. Its presence does not mean the action ran. |
actionResult? | Runtime result: accepted or rejected, plus boundaryView and structured validation/diagnostic details. Never surface its error text as conversational copy without an application-owned mapping. |
presentation? | Exact presentation declared by the application at the current committed .wait() boundary. It is not reconstructed or prefixed by the SDK. |
execution? | SettledExecution caused by an accepted action: waiting_input, completed, failed, cancelled, or cancellation_failed, with runId, workflowId, revision, and boundary kind. Absent for rejected actions and response-only turns. |
projection? | Developer-controlled application projection derived from the same committed revision as execution. |
actionSurface? | Canonical authority derived from the same committed revision as execution. |
boundaryView | Required single causal view for the turn. On action turns it is the same in-process object as actionResult.boundaryView. |
Semantic Rejection Recovery vs. Execution Retry
Rejection recovery does not perform unbounded retry loops. When enabled (mode: "explain" or mode: "repair") and a proposal fails schema validation (WAIT_INPUT_INVALID, ACTION_INPUT_INVALID, WORKFLOW_INPUT_INVALID), the agent makes at most one recovery call:
mode: "explain": Zero execution authority (tools: []). The model explains the constraint to the user with text.mode: "repair": Narrowed execution authority to the pinned target action. If the authorized context contains the missing facts, the model repairs the proposal; otherwise, it clarifies with text.
ts
const turn = await supportAgent.run({ session, message: "I'm Elena" });
console.log(turn.actionResult?.status); // "rejected" or "accepted"
console.log(turn.output); // Text clarification when facts are missing
console.log(turn.modelOutputPhase); // "rejection_recovery"If a model emits narration and a tool proposal in one response and rejection recovery is off, output is omitted, modelOutput preserves the exact pre-settlement text for inspection, and outputDisposition is "withhold" whether the runtime accepts or rejects the proposal. Use presentation, an application/channel projector over boundaryView, or an explicit post-settlement reasoning pass when a response is required.
actionResult.status reports whether the proposal crossed the authority boundary; execution.status reports its durable consequence. Therefore accepted + failed is valid. A Store or Host exception rejects the Promise instead of fabricating execution.failed.
boundaryView always contains { revision, presentation?, projection, actionSurface } from one authoritative frame. On rejection, execution is absent and it describes the unchanged current boundary. In-process, AgentTurnResult.boundaryView and execution reuse the exact object instances from actionResult; serialized HTTP, MCP, and WebSocket transports preserve equal values rather than JavaScript reference identity.
The settled execution shapes are:
text
waiting_input → boundary.kind = input_required
completed → boundary.kind = terminal
failed → boundary.kind = terminal, durable error may be present
cancelled → boundary.kind = terminal
cancellation_failed → boundary.kind = terminal, durable error may be presentAppAgentOptions Specification
| Option | Type | Description |
|---|---|---|
instructions | string | Natural language domain persona, policies, and guidance. |
workflows | Array<Workflow> | Array of compiled workflows the agent is authorized to drive. |
projection / context | Projection | Function | Explicit exposure boundary projecting pure facts visible to the model. |
description | string | Optional description of the agent's purpose. |
conversation | { recentTurns?: number } | Sliding conversation history window (defaults to 8 turns). |
protocol | AgentProtocolConfig | Optional custom system prompt and action schema transformer. |
model | string | Model registry key ("default", "fast"). |
rejectionRecovery | { mode: 'off' | 'explain' | 'repair' } | Optional single-shot semantic rejection recovery ('off', 'explain', or 'repair'). Defaults to { mode: 'off' }. |
9. Type System & State Merging
Invariant provides end-to-end static type inference across workflow steps and session contexts.
ts
type Merge<A, B> = Omit<A, keyof B> & B;When nodes return data, the compile-time type signature extends automatically:
ts
const workflow = app.workflow("typed-demo", {
inputSchema: z.object({ orderId: z.string() }),
})
// Node 1: state is { orderId: string }
.capability("load-order", async ({ input }) => ({
order: { id: input.orderId, amount: 150.00 },
}))
// Node 2: state is { orderId: string, order: { id: string, amount: number } }
.step("calculate-discount", ({ state }) => ({
discount: state.order.amount > 100 ? 20.00 : 0.00,
}))
// Node 3: state contains orderId, order, and discount!
.step("final-total", ({ state }) => ({
finalTotal: state.order.amount - state.discount,
}));Static Types and Runtime Boundaries
TypeScript protects application code only after a value has crossed a validated boundary. Invariant therefore treats provider JSON, persisted SQLite/PostgreSQL values, parsed JSON, and caught errors as unknown, then narrows or decodes them before they become workflow, event, Session, or adapter values. Malformed external data is rejected instead of being trusted through a type assertion.
The repository enforces the same rule in both production and test code. pnpm typesafety rejects explicit any, unchecked @ts-ignore/@ts-nocheck, double assertions, unsafe typed operations, test compilation failures, and measured type coverage below 100%. This gate strengthens—but does not replace—runtime schemas for untrusted input.
10. Extensibility & Adapter Contracts
Invariant is designed to be fully extensible via pluggable storage and model adapters.
RuntimeStore Interface (@invariant-tech/core)
Custom adapters must call validateTransitionCommit(commit) before opening their database transaction. SQLite and PostgreSQL do this automatically.
ts
export interface RuntimeStore {
/**
* Atomically commits state transition + events + outbox commands in a single database transaction.
* Enforces Optimistic Concurrency Control (OCC) using expectedRevision.
*/
commitTransition(commit: TransitionCommit): Promise<void>;
/** Loads materialized execution state by runId. */
loadState(runId: string): Promise<ExecutionState | null>;
/** Reads ordered event log for a runId. */
readEventLog(runId: string): Promise<RuntimeEvent[]>;
/** Acquires worker lease for a runId. */
acquireLease(runId: string, workerId: string, ttlMs: number): Promise<Lease | null>;
/** Renews an active worker lease. */
renewLease(runId: string, leaseId: string, ttlMs: number): Promise<boolean>;
/** Releases worker lease. */
releaseLease(runId: string, leaseId: string): Promise<void>;
/** Finds executions with pending durable work where lease is expired. */
findRunnableExecutions(options: { limit: number }): Promise<string[]>;
/** Compare-and-swap write for durable Session authority. */
saveSession(write: SessionWrite): Promise<void>;
/** Loads durable session by sessionId. */
loadSession(sessionId: string): Promise<DurableSession | null>;
}ModelAdapter & RuntimeModelAdapter Interfaces (@invariant-tech/core)
ts
export interface RuntimeModelReasonParams {
readonly instruction: string;
readonly schema?: Record<string, unknown> | undefined;
readonly tools?: readonly ModelToolDeclaration[] | undefined;
readonly context: Record<string, unknown>;
}
export interface RuntimeModelAdapter {
readonly provider: string;
readonly defaultModel: string;
generateReasoning(params: RuntimeModelReasonParams): Promise<ModelReasonResponse>;
}
export interface ModelAdapter<TModel extends string = string> extends RuntimeModelAdapter {
readonly provider: string;
readonly defaultModel: TModel;
generateReasoning(params: RuntimeModelReasonParams): Promise<ModelReasonResponse>;
}ModelReasonResponse.toolCalls is the canonical provider-neutral proposal array. Adapters must not select the first or last call on the runtime's behalf. The deprecated singular toolCall field remains readable only for custom Beta adapter compatibility.
The Beta interface has no ordered multipart/media field. Official adapters serialize context as text, so provider-native image, audio, video, and document inputs are not currently exposed. See the Model Adapter input matrix. Workflow and .wait() payloads may carry JSON metadata or object-store references, but callers must not assume that File, Blob, Buffer, raw bytes, URLs, or base64 values receive media semantics.
Ecosystem Package Mapping
@invariant-tech/sdk— Core DSL, Session management, Agent definition, Projections, and Tracing.@invariant-tech/sqlite— Embedded local storage adapter with WAL journaling (zero infrastructure).@invariant-tech/postgres— Distributed PostgreSQL event store & Transactional Outbox adapter.@invariant-tech/anthropic— Claude model adapter (ChatAnthropic,ANTHROPIC_MODELS).@invariant-tech/openai— OpenAI model adapter (ChatOpenAI,OPENAI_MODELS).@invariant-tech/google— Gemini model adapter (ChatGoogle,GEMINI_MODELS).
11. Validation Errors & Error Taxonomy
Invariant exports strongly-typed error classes representing authoritative execution boundaries:
ts
import {
AgentModelError,
UnsettledExecutionError,
WorkflowInputValidationError,
WaitInputValidationError,
WaitBoundaryConflictError,
SchemaDialectError,
validateJsonSchemaObject,
} from '@invariant-tech/sdk';| Error Class / Function | Boundary | Code | Description |
|---|---|---|---|
AgentModelError | Agent model boundary | AGENT_MODEL_FAILED | agent.run() could not resolve or execute its selected model adapter. The turn rejects and no successful response is fabricated. |
UnsettledExecutionError | Synchronous Host invariant | UNSETTLED_EXECUTION | The command queue became empty while execution remained transient. Operational invariant violation; never a normal settled outcome. |
WorkflowInputValidationError | Boundary 2 | WORKFLOW_INPUT_INVALID | Thrown when session.startWorkflow() fails input schema validation. Zero state mutation. |
WaitInputValidationError | Boundary 2 | WAIT_INPUT_INVALID | Thrown when session.submitInput() fails active .wait() schema. Zero state mutation. |
WaitBoundaryConflictError | Boundary 2 | WAIT_BOUNDARY_CONFLICT | Thrown when submitInput() is called but session is not at a wait boundary. |
SchemaDialectError | Schema compilation | UNSUPPORTED_SCHEMA_DIALECT | A plain schema used an unknown, unsupported, or malformed keyword; compilation fails closed. |
validateJsonSchemaObject | Boundary 1 | ACTION_INPUT_INVALID | Pure schema validator evaluating an action invocation against its JSON Schema contract. |
| Agent admission | Pre-execution authority | STALE_REASONING_FRAME | The Session/run/revision/boundary observed before inference is no longer current. Zero execution. |
| Agent admission | Proposal cardinality | AMBIGUOUS_MODEL_PROPOSAL | More than one executable provider proposal was returned. All are rejected. |
12. Observability & Debug Mode (ConsoleTraceSink)
Invariant features a unified non-interfering trace emitter and inspectable console sink:
ts
import { invariant, consoleTraceSink } from '@invariant-tech/sdk';
export const app = invariant({
debug: true, // or { level: 'debug' }
observability: {
sinks: [
consoleTraceSink({ level: 'debug' }),
],
},
});Trace events currently emitted
agent.action.received,agent.action.accepted, andagent.action.rejectedworkflow.startedand workflow input rejection eventsnode.entered,node.completed, andnode.failedcapability.dispatched,capability.completed, andcapability.failedwait.input.received, accepted/rejected/conflict diagnosticsllm.call.started,llm.call.completed, andllm.call.failedfor Agent and workflow.reason()calls; reason-node events usesource.kind: "reason"
The exported trace union also reserves lifecycle and Live transport event shapes. A type being exported does not imply that every adapter emits it in the current Beta.
Default Agent action traces include argumentShape.keys and validation path/code, never raw rejected values or custom validator messages. Opting into observability.capture.reasoningBoundary may expose projected context, user messages, tools, and normalized raw model responses; configure it only with an approved sink.
See Workflow Reason Traces for the diagnostic-to-durable event mapping and Implement a Custom Trace Sink for a compilable TraceSink example.