Skip to content

State Management ​

State in Helix Agents consists of two parts: built-in state (conversation history, status, etc.) and custom state (your application-specific data). This guide focuses on custom state.

Why Custom State? ​

Custom state solves several problems:

  1. Tool Coordination - Tools can share data without passing through the LLM
  2. Progress Tracking - Track metrics, counts, and phases of execution
  3. Dynamic Behavior - System prompts and LLM config can adapt based on state
  4. Persistence - State survives across steps and can be resumed after crashes

Defining State Schemas ​

Use Zod to define your state schema:

typescript
import { z } from 'zod';
import { defineAgent } from '@helix-agents/sdk';

const StateSchema = z.object({
  // Counters and metrics
  searchCount: z.number().default(0),
  tokensUsed: z.number().default(0),

  // Phase tracking
  phase: z.enum(['gathering', 'analyzing', 'summarizing']).default('gathering'),

  // Collections
  findings: z.array(z.string()).default([]),
  sourcesVisited: z.array(z.string()).default([]),

  // Flags
  hasFoundAnswer: z.boolean().default(false),

  // Optional values
  currentQuery: z.string().optional(),
  errorCount: z.number().default(0),
});

const agent = defineAgent({
  name: 'researcher',
  stateSchema: StateSchema,
  // ...
});

Default Values ​

Always provide defaults using .default():

typescript
// Good: All fields have defaults
const StateSchema = z.object({
  count: z.number().default(0),
  items: z.array(z.string()).default([]),
  config: z
    .object({
      maxRetries: z.number().default(3),
    })
    .default({}),
});

// Bad: No defaults - will fail at runtime
const StateSchema = z.object({
  count: z.number(), // Error: no default
  items: z.array(z.string()), // Error: no default
});

Nested Objects ​

For nested objects, provide defaults at both levels:

typescript
const StateSchema = z.object({
  metadata: z
    .object({
      createdAt: z.number().optional(),
      updatedAt: z.number().optional(),
      author: z.string().optional(),
    })
    .default({}), // Default empty object

  settings: z
    .object({
      maxResults: z.number().default(10),
      includeImages: z.boolean().default(false),
    })
    .default({}),
});

Reading State ​

In Tools ​

Use context.getState<T>() to read state:

typescript
const searchTool = defineTool({
  name: 'search',
  inputSchema: z.object({ query: z.string() }),
  execute: async (input, context) => {
    // Get typed state
    const state = context.getState<z.infer<typeof StateSchema>>();

    // Read values
    console.log(`Search count: ${state.searchCount}`);
    console.log(`Phase: ${state.phase}`);
    console.log(`Previous findings: ${state.findings.length}`);

    // Check conditions
    if (state.searchCount >= 10) {
      throw new Error('Search limit reached');
    }

    if (state.hasFoundAnswer) {
      return { results: [], note: 'Already found answer' };
    }

    // ...perform search
  },
});

Read-Only Snapshot

getState() returns a read-only snapshot. Do not modify the returned object - use updateState() instead.

In System Prompts ​

System prompt functions receive custom state:

typescript
const agent = defineAgent({
  name: 'adaptive-assistant',
  stateSchema: StateSchema,
  systemPrompt: (state) => {
    let prompt = 'You are a research assistant.\n\n';

    // Adapt based on phase
    if (state.phase === 'gathering') {
      prompt += 'Focus on finding relevant information. Use search liberally.\n';
    } else if (state.phase === 'analyzing') {
      prompt += 'Analyze the findings. No more searching needed.\n';
    } else {
      prompt += 'Provide a final summary of your findings.\n';
    }

    // Include context from state
    if (state.findings.length > 0) {
      prompt += `\nFindings so far:\n${state.findings.map((f, i) => `${i + 1}. ${f}`).join('\n')}`;
    }

    return prompt;
  },
  // ...
});

In LLM Config Override ​

Access state when adjusting LLM config:

typescript
const agent = defineAgent({
  name: 'smart-agent',
  stateSchema: StateSchema,
  llmConfig: {
    model: openai('gpt-6-luna'),
  },
  llmConfigOverride: (customState, stepCount) => {
    // Use more capable model for complex phases
    if (customState.phase === 'analyzing') {
      return {
        model: openai('gpt-6.1-sol'),
      };
    }

    // Think less per step as we progress
    if (stepCount > 10) {
      return { providerOptions: { openai: { reasoningEffort: 'low' } } };
    }

    return {};
  },
  // ...
});

Updating State ​

Basic Updates ​

Use context.updateState<T>(updater) with Immer's draft pattern:

typescript
execute: async (input, context) => {
  // Update state by mutating the draft
  context.updateState<z.infer<typeof StateSchema>>((draft) => {
    draft.searchCount++;
    draft.currentQuery = input.query;
  });

  // ... rest of tool execution
};

Immer Draft Pattern ​

Immer lets you write "mutating" code that produces immutable updates:

typescript
context.updateState<State>((draft) => {
  // Direct property assignment
  draft.count = 5;

  // Increment/decrement
  draft.count++;
  draft.count--;

  // Array push (most common pattern)
  draft.items.push('new item');

  // Array operations
  draft.items.pop();
  draft.items.shift();
  draft.items.unshift('first');
  draft.items.splice(1, 0, 'inserted');

  // Nested object modification
  draft.config.maxRetries = 5;

  // Set optional value
  draft.currentQuery = 'new query';

  // Clear optional value
  draft.currentQuery = undefined;
});

Multiple Updates ​

You can call updateState multiple times in one execution:

typescript
execute: async (input, context) => {
  // First update: mark as started
  context.updateState<State>((draft) => {
    draft.phase = 'processing';
    draft.startedAt = Date.now();
  });

  // Do some work...
  const results = await performWork();

  // Second update: record results
  context.updateState<State>((draft) => {
    draft.results.push(...results);
    draft.processedCount += results.length;
  });

  // Third update: mark as done
  context.updateState<State>((draft) => {
    draft.phase = 'done';
    draft.completedAt = Date.now();
  });

  return { success: true };
};

Per-Turn State (statePatch) ​

A new turn can carry a change to the session's state: a statePatch, an RFC 7396 JSON Merge Patch of customState. Objects merge recursively, null deletes a key, and arrays and scalars replace.

typescript
// A new turn whose state switches the agent into review mode and drops a stale draft.
await executor.execute(
  agent,
  {
    message: 'Review what you have so far',
    statePatch: { mode: 'reviewing', draft: null },
  },
  { sessionId }
);

// The same on a resume with a new message, and on a retry.
await executor.resume(agent, sessionId, {
  mode: 'with_message',
  message: 'Now summarize',
  statePatch: { mode: 'summarizing' },
});
await executor.retry(agent, sessionId, { statePatch: { mode: 'drafting' } });

The patch is applied once, to the turn's entry state (the stored state, or the state seed of a new session), and committed in the turn's run-start commit, so the turn's input checkpoint records it and every tool of the turn sees it. Only the keys it touches are validated against the agent's stateSchema; a refusal fails the entry with validation_error before anything is written. The schema's object-level refinements (.refine() / .superRefine() on the whole state object) are checked, on the whole merged state: a patch that keeps each field valid but breaks a cross-field rule such as "a < b" is refused the same way, its validation_error naming the statePatch and the refinement's message. A refinement runs on the stored values as they are, so write it to tolerate a key a later schema change made invalid (one that throws is refused too). A retry's patch stacks on the state the retry restores.

A with_message resume over an approved approval-gated call runs that call first (a drain); its patch then applies after the drain. If the drained state no longer accepts the patch, the run fails state_entry_patch_refused (HTTP 409): the drained results stay, and the message is not appended. useHelixChat's retry() re-sends it.

Where a host sets it: handleChatStream's turnStatePatch dep (see the AI SDK reference), never from the request body. agent-server's /start and /resume take a statePatch body field only when the host opts in (AgentServerConfig.clientState: a statePatch is refused by default, /start's state accepted; a hook decides both per request; see the agent-server reference). Temporal and DBOS refuse a non-empty statePatch (framework_not_supported) until #177 MR 2.

Start work is once per turn ​

onAgentStart and the initial onMessage run once per turn, and their updateState() writes are committed in the turn's start commit before its first model call, so a stop or a crash in that call keeps them and a recovered run never runs them again. See Hooks: start signals.

State Persistence ​

How It Works ​

  1. After each step, state is saved to the StateStore
  2. State stores (Memory, Redis, Cloudflare) handle persistence
  3. On resume, state is loaded from the store to continue execution
Step 1 → LLM Call → Tool Execution → State Saved → Checkpoint Created
                                                         ↓
Step 2 → LLM Call → Tool Execution → State Saved → Checkpoint Created
                                                         ↓
                                                   [Crash/Restart]
                                                         ↓
Resume  → State Loaded from Checkpoint → Step 3 continues...

Checkpoints ​

A checkpoint records a point in the session: its message count, stream position, step count and custom state. Every commit writes one: a run's start, a turn's start work (start), each completed step, injected messages, a rewind, and the terminal state of a run (saveState does not). They enable:

  • Crash recovery - Resume execution after process dies
  • Time-travel - Go back to any previous step
  • Branching - Fork execution from a historical point
typescript
// List checkpoints for a session
const checkpoints = await stateStore.listCheckpoints(sessionId);

for (const cp of checkpoints.checkpoints) {
  console.log(`Step ${cp.stepCount}: ${cp.id}`);
}

// Get full state from a checkpoint
const checkpoint = await stateStore.getCheckpoint(checkpointId);
console.log('State at checkpoint:', checkpoint.state);

Staging and Commits ​

State changes are staged before being committed. This enables atomic step boundaries:

  1. Tool executes - Changes are staged (not yet visible)
  2. Step completes - Changes are promoted to main state
  3. Checkpoint created - in the same atomic step commit

If interrupted mid-step, staged changes are discarded and execution resumes from the last checkpoint.

See Checkpoints for detailed documentation.

Concurrent updates ​

The framework does not add cross-process coordination for concurrent writes to the same datum — parallel writers to the same scalar or array element resolve last-writer-wins. Two patterns DO compose without a lock, because the framework tracks per-writer deltas:

  • Parallel tools that append to a top-level array (draft.results.push(...)) — every tool's appends survive.
  • Parallel sub-agent afterSubAgent folds — each child folding into a disjoint slice of customState (its own array element or key) composes at element granularity on every runtime. See Sub-Agents → Folding parallel results into customState.

Writes are recorded per top-level key: an index or keyed write inside one key (draft.blocks[3] = x, draft.byId[id] = item) replaces that whole key, so two parallel tools writing inside the same top-level key are last-writer-wins. Give each concurrent writer its own top-level key (or append), and updates always compose. When several calls must read-modify-write the same key, put their tool in a concurrencyGroup: a group's calls run one at a time, each on the state the previous one left.

JSON Serialization ​

State must be JSON-serializable:

typescript
// Good: JSON-serializable types
const StateSchema = z.object({
  count: z.number(),
  name: z.string(),
  tags: z.array(z.string()),
  metadata: z.record(z.string()),
  timestamp: z.number(), // Store dates as numbers
  data: z.unknown(), // Any JSON value
});

// Bad: Non-serializable types
const StateSchema = z.object({
  callback: z.function(), // Functions can't serialize
  date: z.date(), // Dates become strings
  map: z.map(), // Maps become objects
  set: z.set(), // Sets become arrays
  regex: z.instanceof(RegExp), // Becomes empty object
});

Working with dates:

typescript
const StateSchema = z.object({
  createdAt: z.number().default(() => Date.now()),
  updatedAt: z.number().optional(),
});

// In tool
context.updateState<State>((draft) => {
  draft.updatedAt = Date.now();
});

// Reading
const state = context.getState<State>();
const date = new Date(state.createdAt);

State Size and Storage Limits ​

State is for control-flow values, not durable artifacts. Each tool's state writes are staged as a row, folded into the session's customState at the step commit, and copied into that commit's checkpoint, so state lives in the store's rows, and some stores cap a row. A store declares its limits as SessionStateStore.limits (StoreLimits: maxStateBytes, maxStagedWriteBytes, maxMessageBytes, each in UTF-8 bytes of the JSON the store binds; Infinity means none):

StoreLimit
Cloudflare DO-SQLiteOne maxStateBytes for every operation, default 1.8 MB (DEFAULT_MAX_STATE_BYTES, 200 KB under Cloudflare's 2 MB SQL value cap). Set it on DurableObjectAgentConfig.maxStateBytes (or DOStateStoreOptions.maxStateBytes).
Cloudflare D1The same single limit, D1StateStoreOptions.maxStateBytes, default 1.8 MB (DEFAULT_D1_MAX_STATE_BYTES). On CF Workflows every step result is also capped at 1 MiB (below).
RedismaxStagedWriteBytes only (one tool's staged writes), default 1.8 MB (DEFAULT_REDIS_MAX_STAGED_WRITE_BYTES): a Lua-time budget for promoteStaging, not a storage cap. The folded state and message rows have no limit.
PostgresNone declared. A value over Postgres's own jsonb cap (SQLSTATE 54000, POSTGRES_MAX_JSONB_BYTES = 268 435 455 bytes) is mapped to the same typed error.
In-memoryNone (process memory).

Every store refuses an oversized write with one core error, StateSizeExceededError (code: 'state_size_exceeded', category state, never retryable, HTTP 413; check it with isStateSizeExceededError), and nothing of the refused write lands. It names the operation (stage, step, state or append), limitBytes, sizeBytes and the changedKeys: keys and byte counts, never values. What the run does with it depends on where it was refused:

  • One tool's write (staging). The tool ran, so its result is recorded (as a failure), and its state writes are discarded: no in-memory merge, no state_patch, no onStateChange, and afterTool sees success: false. Its tool result is a typed failed row (metadata.helixToolFailed: true; check it with isStateWriteRejectedToolResult):

    json
    {
      "content": "{\"error\":\"Tool 'save' ran but its state write (2100042 bytes) exceeds the store's 1800000-byte limit (keys: report) and was discarded; store large values outside agent state and keep a reference\"}",
      "metadata": {
        "helixToolFailed": true,
        "helixSynthetic": {
          "kind": "state_write_rejected",
          "code": "state_size_exceeded",
          "operation": "stage",
          "sizeBytes": 2100042,
          "limitBytes": 1800000,
          "changedKeys": ["report"]
        }
      }
    }

    The tool_end chunk carries the same text. The model sees the failure and can recover; parallel siblings are unaffected, and the run continues. A finishWith tool whose write is refused produces no output. A tool that threw is never this row: it keeps its own failed row.

  • A tool result too large to store. On runtime-js and the CF DO, a tool or sub-agent result row over the store's maxMessageBytes, measured exactly as the store binds it (core messageRowBytes: UTF-8 bytes of the whole row's JSON), is replaced before its tool_end and afterTool / afterSubAgent by a plain failed row (helixToolFailed: true, no other marker), and the run continues: the call ran (its staged writes stay), so its outcome is recorded, as a failure, and the model sees it on its next step:

    json
    {
      "content": "{\"error\":\"Tool 'fetch' ran, but its result was too large to record: the recorded row would be 2000123 bytes, over the 1800000-byte message limit. Return large data by reference (store it outside the result and return an id)\"}",
      "metadata": { "helixToolFailed": true }
    }

    The tool_end carries the same text and no result, and the hook's error is the StateSizeExceededError (operation: 'step'). It applies to every result row runtime-js records, a success row or a failed one: a server tool's (a finishWith tool so refused produces no output), an in-process or remote sub-agent's ("Sub-agent 'y' ran, …"), a companion tool's ("Companion tool 'x' ran, …"), an approved tool's and a submitted client result's (in the resume drain or a with_message entry), a child outcome injected at a step boundary or on resume, and a finished remote child's result that a later entry settles before its input (core settleRemoteChildrenBeforeInput, its maxMessageBytes; the ref keeps the child's own status; CF Workflows has no such entry since RM-69, its step commits each remote child's result itself). A FAILED row too large to record (a tool that threw, a beforeTool hook error, a failed or undispatchable child: an error message over the limit) gets the same row with "its error" and its own advice: "Tool 'x' ran, but its error was too large to record: the recorded row would be N bytes, over the M-byte message limit. Keep error messages short (store large details outside the error and return an id)". CF Workflows writes the same row, from the same core builder (createResultTooLargeToolResult), for a result over its 1 MiB step-result cap (below), where the measure clause reads "the conservative measure passed N bytes, over the 1048576-byte step-result limit". Temporal and DBOS do not convert it: the stores they usually run on (Redis, Postgres) declare no message limit. An oversized assistant reply is not a result: it fails the run (next bullet).

  • A row the runtime injects itself (A13). A persistent companion's hidden completion notice embeds its output, so its row can be too large even when the output is not (MA-37, MA-38). It is measured the same way and, over the limit, delivered as ONE hidden notice saying so (Sub-agent 'y' completed, but its result was too large to record: …, or … failed, but its error was too large to record: …), so the child is marked delivered and the notice is never re-injected; on CF Workflows a turn's notices are also fitted together under the 1 MiB step-result cap (the largest replaced first). CF Workflows also fits, against its store's maxMessageBytes (D1's limit can be set below 1 MiB) and the step-result cap, the sub-agent result record (a remote child's error record and its last-resort row, a respawned child's record and its poll), an approved tool's row in the resume drain, a companion tool's result row ("Companion tool 'x' ran, …") and an accepted sub-agent-complete event (MA-31). Each lands as the typed too-large row, so the call is paired and the run continues. See the companion notifier.

  • A commit. A step commit over the limit (the folded state, or the model's own reply) fails the run with errorDetail.code: 'state_size_exceeded' and writes nothing of the step. Its failure terminal commits the state the store already holds (on CF Workflows with the onAgentFail hooks' writes), never the refused one, so the session never stays active. On runtime-js and the CF DO the runtime also measures the folded state before the step commit (on a store with a finite maxStateBytes), so the run fails earlier, naming the step's keys, and an onAgentComplete hook whose writes grow the state over the limit ends the run failed the same way, over the stored state.

  • A submitted client-tool answer. Submitted answers wait in the session's stored pending map until the batch resumes, so an answer that would push it over the limit is refused with a 413 state_size_exceeded before anything is stored (the batch's earlier answers stay stored). A submitToolResultSchema whose maxResultBytes is over the store's finite maxStateBytes is refused when the executor or the DO is constructed (see the submit size limit).

A chat message that is too large. A chat send whose user message is itself over the store's limit is refused at the entry's run-start commit with a 413 state_size_exceeded (cause: 'append', limitBytes, sizeBytes). The commit was refused, so the message is not stored and no run starts. The stock AI SDK client's behaviour is measured by tests on ai 6.0.0, 6.0.49, 6.0.91, 6.0.230 and 6.0.281, all alike: the chat ends in status: 'error', the user's message stays in the transcript, and nothing re-sends it (one request, even with sendAutomaticallyWhen: () => true). See the AI SDK reference for the client side.

CF Workflows: a 1 MiB step result. The Workflows engine persists every step.do result and caps one at 1 MiB (Cloudflare's limits), so on that runtime 1 MiB (CFW_MAX_STEP_RESULT_BYTES) is the effective limit for a model reply, a tool result and a sub-agent or companion outcome, each of which rides a step result. The runtime measures each result before its step returns. A tool or sub-agent / companion result over it becomes a typed failed result (it ran, its result was too large to record) and the run continues, so the engine never re-runs it; a model reply over it fails the run state_size_exceeded and the model is never called again.

On Temporal and DBOS a refused tool write gets the same typed row, but afterTool receives a plain Error carrying its text rather than the StateSizeExceededError itself (the result crosses a serialized boundary).

If you find yourself wanting to stash a large blob (LLM response, research bundle, file contents, big JSON document) in agent state, don't. Write it to your own backing store (D1 / R2 / Postgres / S3) inside the tool that produced it, and store only the id/reference in agent state:

typescript
defineTool({
  name: 'fetchAndPersist',
  inputSchema: z.object({ url: z.string() }),
  execute: async (input, context) => {
    const blob = await fetchLargeArtifact(input.url);

    // Bad: tool result + state both carry the full ~2 MB blob
    // context.updateState<State>((draft) => { draft.lastBlob = blob; });

    // Good: durable store holds the artifact, state holds the reference
    const id = await db.artifacts.insert(blob);
    context.updateState<State>((draft) => {
      draft.lastArtifactId = id;
    });
    return { ok: true, id };
  },
});

This pattern is recommended even when SDK fixes raise the practical limits. State is a per-tick delta channel — keeping it small makes checkpoints cheap, snapshot reads fast, and triage straightforward.

Advanced Patterns ​

Phase-Based Execution ​

Use state to track execution phases:

typescript
const StateSchema = z.object({
  phase: z.enum(['init', 'search', 'analyze', 'summarize', 'done']).default('init'),
  phaseData: z.record(z.unknown()).default({}),
});

const agent = defineAgent({
  name: 'phased-agent',
  stateSchema: StateSchema,
  systemPrompt: (state) => {
    const instructions = {
      init: 'Start by understanding the user request.',
      search: 'Search for relevant information.',
      analyze: 'Analyze what you found.',
      summarize: 'Provide a final summary.',
      done: 'Task complete.',
    };
    return instructions[state.phase];
  },
  // ...
});

// Tool to advance phase
const advancePhaseTool = defineTool({
  name: 'advance_phase',
  description: 'Move to the next execution phase',
  inputSchema: z.object({}),
  execute: async (_, context) => {
    const state = context.getState<z.infer<typeof StateSchema>>();
    const phases = ['init', 'search', 'analyze', 'summarize', 'done'];
    const currentIndex = phases.indexOf(state.phase);

    if (currentIndex < phases.length - 1) {
      context.updateState<typeof state>((draft) => {
        draft.phase = phases[currentIndex + 1] as typeof state.phase;
      });
    }

    return { newPhase: phases[currentIndex + 1] };
  },
});

Accumulating Results ​

Common pattern for gathering results:

typescript
const StateSchema = z.object({
  results: z
    .array(
      z.object({
        source: z.string(),
        data: z.unknown(),
        quality: z.number(),
      })
    )
    .default([]),
  bestResult: z
    .object({
      source: z.string(),
      data: z.unknown(),
      quality: z.number(),
    })
    .optional(),
});

const gatherTool = defineTool({
  name: 'gather',
  inputSchema: z.object({ source: z.string() }),
  execute: async (input, context) => {
    const data = await fetchFromSource(input.source);
    const quality = assessQuality(data);

    context.updateState<z.infer<typeof StateSchema>>((draft) => {
      // Add to results
      draft.results.push({ source: input.source, data, quality });

      // Track best result
      if (!draft.bestResult || quality > draft.bestResult.quality) {
        draft.bestResult = { source: input.source, data, quality };
      }
    });

    return { quality };
  },
});

Rate Limiting ​

Track usage and enforce limits:

typescript
const StateSchema = z.object({
  apiCalls: z.number().default(0),
  lastCallTime: z.number().default(0),
  rateLimitErrors: z.number().default(0),
});

const apiTool = defineTool({
  name: 'api_call',
  inputSchema: z.object({ endpoint: z.string() }),
  execute: async (input, context) => {
    const state = context.getState<z.infer<typeof StateSchema>>();

    // Check rate limit
    const timeSinceLastCall = Date.now() - state.lastCallTime;
    if (timeSinceLastCall < 1000) {
      // 1 second minimum between calls
      await new Promise((resolve) => setTimeout(resolve, 1000 - timeSinceLastCall));
    }

    // Check total calls
    if (state.apiCalls >= 100) {
      throw new Error('API call limit reached (100 calls)');
    }

    // Track the call
    context.updateState<typeof state>((draft) => {
      draft.apiCalls++;
      draft.lastCallTime = Date.now();
    });

    try {
      return await makeApiCall(input.endpoint);
    } catch (error) {
      if (error.message.includes('rate limit')) {
        context.updateState<typeof state>((draft) => {
          draft.rateLimitErrors++;
        });
      }
      throw error;
    }
  },
});

Message Metadata ​

All message types support an optional metadata field for attaching arbitrary data. This is useful for tracking message origins, timing, user context, and debugging.

Adding Metadata to Messages ​

Messages in the conversation history can include metadata:

typescript
import type { UserMessage } from '@helix-agents/core';

const message: UserMessage = {
  role: 'user',
  content: 'Hello',
  metadata: {
    source: 'web-ui',
    userId: 'user-123',
    timestamp: Date.now(),
    sessionId: 'sess-456',
  },
};

Common Metadata Keys ​

Use COMMON_METADATA_KEYS for standardized key names:

typescript
import { COMMON_METADATA_KEYS } from '@helix-agents/core';

const message = {
  role: 'user',
  content: 'Hello',
  metadata: {
    [COMMON_METADATA_KEYS.SOURCE]: 'web-ui',
    [COMMON_METADATA_KEYS.TIMESTAMP]: Date.now(),
    [COMMON_METADATA_KEYS.HIDDEN]: false,
  },
};

// Available keys:
// SOURCE - Origin of the message ('web-ui', 'api', 'tool')
// HIDDEN - Whether to hide from UI
// TIMESTAMP - When the message was created
// DURATION - Processing time in ms
// MODEL - LLM model used
// IS_SUB_AGENT - Whether from a sub-agent
// PARENT_SESSION_ID - Parent session ID for sub-agents
// TAGS - Array of tags

Filtering Messages by Metadata ​

typescript
import { filterByMetadata, getMessagesWithMetadataKey } from '@helix-agents/core';

// Filter by metadata value
const webMessages = filterByMetadata(messages, (m) => m.source === 'web-ui');
const hiddenMessages = filterByMetadata(messages, (m) => m.hidden === true);

// Get messages with a specific metadata key
const messagesWithSource = getMessagesWithMetadataKey(messages, 'source');

Stripping Metadata ​

When sending messages to external systems, you may want to remove metadata:

typescript
import { stripMetadata } from '@helix-agents/core';

// Remove metadata from a single message
const cleanMessage = stripMetadata(message);
// cleanMessage.metadata is undefined

// Clean all messages
const cleanMessages = messages.map(stripMetadata);

Metadata Persistence ​

Message metadata is automatically persisted by all state stores (Memory, Redis, Postgres, Cloudflare D1 and Durable Objects). Messages are stored per session, apart from SessionState; when you read them back, the metadata is preserved:

typescript
const { messages } = await stateStore.getMessages(sessionId, { limit: 1000 });
const userMsg = messages.find((m) => m.role === 'user');
console.log(userMsg?.metadata); // { source: 'web-ui', ... }

Gotchas and Limitations ​

1. No Direct Array Index Modification in Parallel ​

When tools run in parallel, an index or keyed write replaces its whole top-level key, so a sibling's write to the same key is lost:

typescript
// Lost when two calls run in the same step: each replaces all of `items` / `itemsById`
context.updateState<State>((draft) => {
  draft.items[0] = 'modified';
  draft.itemsById[id] = item;
});

// Composes across parallel calls: appends
context.updateState<State>((draft) => {
  draft.items.push(newItem);
});

For read-modify-write of one key across several calls, use a concurrencyGroup.

2. Type Casting Required ​

TypeScript can't infer the state type from context alone:

typescript
// Required: explicit type parameter
context.updateState<z.infer<typeof StateSchema>>((draft) => {
  draft.count++; // TypeScript knows about 'count'
});

// Common pattern: define type alias
type State = z.infer<typeof StateSchema>;

context.updateState<State>((draft) => {
  draft.count++;
});

3. Default Values Must Be Provided ​

State is initialized from schema defaults on first run:

typescript
// Schema with defaults
const StateSchema = z.object({
  count: z.number().default(0), // ✓ Default provided
  items: z.array(z.string()).default([]), // ✓ Default provided
  name: z.string().optional(), // ✓ Optional fields OK
});

// First run: state is { count: 0, items: [], name: undefined }

4. State Changes Stream to Clients ​

Updates are sent as RFC 6902 patches. Large state updates mean more streaming data:

typescript
// Efficient: small incremental updates
context.updateState<State>((draft) => {
  draft.items.push(newItem); // Small patch: add operation
});

// Less efficient: replacing large arrays
context.updateState<State>((draft) => {
  draft.items = [...draft.items, newItem]; // Replace entire array
});

Next Steps ​

Released under the MIT License.