State management
Learn how to declare, read, write, and reset flow-scoped state fields to share data between skills across event cycles.
State is the flow-scoped key-value store that lets skills persist and share data across multiple event cycles within a session. Every flow can declare state fields, and every skill within that flow can read and write them using the GetState and SetState NSL actions.
What is state?
State fields are named variables that belong to a flow instance. Unlike local variables (which exist only for the duration of a single skill execution), state values survive across skill invocations within the same flow. A skill that runs in response to a conversation_started event can write a value to state, and a completely different skill triggered by a later user_message event can read that value.
State serves three core purposes:
- Data sharing -- Skills within the same flow exchange computed results (LLM outputs, prompt fragments, extracted values) without needing to pass parameters directly.
- Behavioral control -- Skills modify state values that influence future event routing, enabling patterns like dynamic skill selection and conversation-phase tracking.
- Caching -- Expensive computations (such as prompt compilation) are stored in state so they can be reused across multiple event cycles without recomputation.
Declaring state fields in flow YAML
State fields are declared in the state_fields section of a flow YAML file. Each field has an identifier, a default value, and a scope.
state_fields:
- title: ""
idn: cached_base_prompt # Unique name within this flow
default_value: "" # Value assigned when state is initialized
scope: user # Per-user state (private to each conversation)
- title: ""
idn: waiting_mode_active
default_value: "False" # Boolean stored as string
scope: user
- title: ""
idn: conversation_started_newo_voice_skill
default_value: v2v_conversation_started # Holds a skill identifier
scope: agent # Shared across all users| Field | Description |
|---|---|
idn | Unique identifier for the state field within the flow. Used as the name parameter in GetState and SetState calls. |
default_value | The initial value assigned when the flow state is created. All values are stored as strings. |
scope | Either user (per-user, private to a single conversation participant) or agent (shared across all users of the agent). |
title | Optional human-readable label for documentation and UI display. |
:::
๐๏ธ NOTE
All state values are strings. Booleans are stored as "True" and "False" (capitalized). Numbers are stored as their string representation (e.g., "0", "15"). To use a state value as a number in NSL, apply a Jinja filter such as | int.
:::
Real-world example: CAFollowUpFlow
The CAFollowUpFlow in ConvoAgent declares six state fields that track the follow-up timer lifecycle:
state_fields:
- title: ""
idn: chat_lifecycle_state # Tracks speech phases: "Silence", "AgentSpeaking", etc.
default_value: Silence
scope: user
- title: ""
idn: event_block # Blocks follow-up events after conversation ends
default_value: "False"
scope: user
- title: ""
idn: follow_up_state # Controls follow-up behavior: "active", "disabled_auto", "disabled_manual"
default_value: ""
scope: user
- title: ""
idn: followup_counter # Counts consecutive follow-up attempts
default_value: "0"
scope: user
- title: ""
idn: message_queue # Queued messages waiting to be sent
default_value: "[]"
scope: user
- title: ""
idn: user_interrupt # Whether the user interrupted the agent
default_value: "False"
scope: userEach field enables a different aspect of follow-up behavior. The chat_lifecycle_state field tracks whether the conversation is in a silence period (warranting a follow-up), while followup_counter prevents infinite follow-up loops by counting attempts.
Reading and writing state: GetState and SetState
The GetState and SetState actions are the NSL interface for working with state fields at runtime.
GetState
Reads the current value of a state field. Returns the value as a string.
GetState(name="<state_field_idn>")
SetState
Writes a new value to a state field. The value is stored persistently for the duration of the state's lifecycle.
SetState(name="<state_field_idn>", value="<new_value>")
Basic usage
{# Read a state value into a local variable #}
{% set cached_prompt = GetState(name="cached_base_prompt") %}
{# Write a new value to state #}
{{SetState(name="cached_base_prompt", value=cached_prompt)}}Pattern: caching expensive results
One of the most common state patterns is caching the output of an expensive computation so it can be reused across events. This example from CAMainFlow caches the compiled base prompt:
{# Build cached prompt in case it doesn't exist #}
{% set cached_prompt = GetState(name="cached_base_prompt") %}
{% if not cached_prompt.strip() %}
{% set cached_prompt = prompt_build_base(user_id=user_id) %}
{{SetState(name="cached_base_prompt", value=cached_prompt)}}
{% endif %}
{{Return(val=cached_prompt)}}The first time this skill runs, cached_base_prompt is empty (its default), so it calls prompt_build_base (an expensive multi-step prompt assembly process) and stores the result. On subsequent invocations within the same session, the cached value is returned immediately.
Pattern: tracking retry attempts
State fields can store counters that track progress across event cycles. This example from MWmainFlow limits retries for a task execution:
{% set attempt_number = GetState(name="attempt_number") | int(default=0) %}
{% if attempt_number >= 3 %}
{{ SetState(name="attempt_number", value="0") }}
{{ Return(val="true") }}
{% else %}
{% set attempt_number = attempt_number + 1 %}
{{ SetState(name="attempt_number", value=attempt_number | string) }}
{{ Return(val="false") }}
{% endif %}The | int(default=0) Jinja filter converts the string state value to an integer. After incrementing, the value is converted back to a string with | string before writing to state.
Pattern: conversation phase tracking
Multiple skills can coordinate by reading and writing the same state field to track conversation phases. In CAFollowUpFlow, several skills work together through the follow_up_state field:
{# EnableFollowUp.nsl -- activate follow-up behavior #}
{{SetState(name="follow_up_state", value="active")}}{# DisableFollowUp.nsl -- deactivate with reactivation mode #}
{% set act = GetTriggeredAct() %}
{% set reactivation_mode = act.arguments.reactivation_mode or "auto" %}
{% if reactivation_mode == "auto" %}
{{SetState(name="follow_up_state", value="disabled_auto")}}
{% endif %}
{% if reactivation_mode == "manual" %}
{{SetState(name="follow_up_state", value="disabled_manual")}}
{% endif %}{# SetFollowupSkill.nsl -- check follow_up_state before sending #}
{% if GetState(name="follow_up_state").startswith("disabled") %}
{{Return()}}
{% endif %}The follow_up_state value acts as a lightweight state machine. Different skills transition between states ("active", "disabled_auto", "disabled_manual"), and downstream skills branch their logic based on the current phase.
Pattern: resetting state at session boundaries
When a new session starts, skills typically reset state fields to their default values to ensure a clean conversation context:
{# ResetFollowUpStateSkill.nsl -- runs on session_started and end_session events #}
{{SetState(name="followup_counter", value="0")}}
{{SetState(name="user_interrupt", value="False")}}
{{SetState(name="chat_lifecycle_state", value="Silence")}}
{{SetState(name="message_queue", value="[]")}}
{{SetState(name="event_block", value="False")}}
{{SetState(name="follow_up_state", value="active")}}{# ResetSessionStateSkill.nsl -- runs on conversation start #}
{{SetState(name="fast_prompt", value="")}}
{{SetState(name="cached_base_prompt", value="")}}:::
โโ IMPORTANT
State fields are initialized with their default_value when the flow is first loaded. However, resetting state at session boundaries is not automatic -- it must be done explicitly by a skill bound to the session_started or end_session event. If a state field is not reset, it retains its value from the previous session.
:::
How skills exchange data through state
Because state fields are shared across all skills within the same flow, they act as a communication bus. One skill writes a computed value; another skill reads it later.
Storing LLM results for reuse
The CAThoughtsFlow demonstrates a common pattern where one skill generates a prompt, stores it in state, and a caching skill decides whether to regenerate or reuse it:
{# CachePromptSkill.nsl #}
{% if GetPersonaAttribute(id=userId, field="thoughts_new_session") == "True" %}
{{SetPersonaAttribute(id=userId, field="thoughts_new_session", value="False")}}
{% set return_result = buildPromptSkill(userId=userId, taskIdn=taskIdn) %}
{{SetState(name="last_no_task_prompt", value=return_result)}}
{% else %}
{% set return_result = GetState(name="last_no_task_prompt") %}
{% if not return_result.strip() %}
{% set return_result = buildPromptSkill(userId=userId, taskIdn=taskIdn) %}
{{SetState(name="last_no_task_prompt", value=return_result)}}
{% endif %}
{% endif %}
{{Return(val=return_result)}}This skill uses persona attributes to detect a new session and state to cache the compiled prompt. The first call in a session regenerates the prompt; subsequent calls return the cached version from state.
Multi-step workflows with state
The ScenarioWriter agent uses state to manage a multi-step content-generation workflow:
{# start.nsl -- entry point for scenario generation #}
{% set request = GetTriggeredAct().text %}
{% if GetState(name="mode") == "conversation" %}
{% if request.lower() == "no" %}
{{SetState(name="mode", value="scenario_builder")}}
{{SendMessage(message="You can now submit a new request.")}}
{{Return()}}
{% endif %}
{{SendMessage(message="Fixing the scenario.")}}
{{fix_scenario(request=request)}}
{{Return()}}
{% endif %}
{# ... generate scenario ... #}
{{SetState(name="original_scenario", value=formatted_scenario)}}
{{SetState(name="mode", value="conversation")}}{# fix_scenario.nsl -- reads stored scenarios from state for editing #}
{% set prompt %}
...
```original version
{{GetState(name="original_scenario")}}
```
```last version
{{GetState(name="fixed_scenario")}}
```
...
{% endset %}
{% set new_scenario = text_generation(prompt=prompt) %}
{{SetState(name="fixed_scenario", value=new_scenario)}}The mode state field controls which branch of logic executes. The original_scenario and fixed_scenario fields preserve content across the back-and-forth editing conversation, allowing the user to iteratively refine results without losing previous versions.
Dynamic skill routing with skill_idn_from_state
The skill_idn_from_state mechanism allows event bindings to determine which skill to invoke at runtime by reading a skill identifier from a state field. This is the dynamic counterpart to static skill_idn routing.
How it works
In an event binding, setting skill_selector to skill_idn_from_state tells the platform: "Instead of calling a hardcoded skill, read the state field named in state_idn and call whatever skill identifier is stored there."
events:
# Static routing: always calls ConversationStartedSkill
- idn: conversation_started
skill_selector: skill_idn
skill_idn: ConversationStartedSkill
state_idn: null
integration_idn: newo_chat
connector_idn: null
interrupt_mode: queue
# Dynamic routing: reads skill name from state
- idn: conversation_started
skill_selector: skill_idn_from_state
skill_idn: null # Not used
state_idn: conversation_started_newo_voice_skill # State field to read
integration_idn: newo_voice
connector_idn: null
interrupt_mode: queueThe corresponding state field provides the default skill:
state_fields:
- idn: conversation_started_newo_voice_skill
default_value: v2v_conversation_started # Default skill identifier
scope: agent # Agent-scoped: same for all usersWhen the conversation_started event arrives from the newo_voice integration, the platform reads the conversation_started_newo_voice_skill state field. Initially this returns v2v_conversation_started. But any skill can change this routing at runtime:
{# Switch to a different conversation-start handler #}
{{SetState(name="conversation_started_newo_voice_skill", value="OutboundConversationStartedSkill")}}After this SetState call, the next conversation_started event from newo_voice will invoke OutboundConversationStartedSkill instead.
Production example: phone reply skill switching
CAMainFlow uses skill_idn_from_state to route user_message events from the newo_voice integration:
events:
- idn: user_message
skill_selector: skill_idn_from_state
skill_idn: null
state_idn: phone_reply_skill # Read skill name from state
integration_idn: newo_voice
connector_idn: newo_voice_connector
interrupt_mode: interrupt
state_fields:
- idn: phone_reply_skill
default_value: UserPhoneReplySkill # Default for inbound calls
scope: user # Per-user: each caller can have different routingBy default, voice messages route to UserPhoneReplySkill. For outbound call campaigns, a setup skill writes a different value (such as UserOutboundReplySkill) to phone_reply_skill, dynamically changing how user messages are handled for that user's session.
:::
๐๏ธ NOTE
The skill_idn_from_state mechanism currently appears in CAMainFlow for two event bindings: conversation_started on the newo_voice integration and user_message on the newo_voice connector. Both use state_idn to point to state fields that hold skill identifiers.
:::
Scope comparison: state vs other storage
The Newo platform provides several storage mechanisms, each scoped to a different entity and lifecycle. Choosing the right one depends on what data is being stored and how long it needs to persist.
| Storage type | Scope | Lifetime | Access pattern | Use case |
|---|---|---|---|---|
State fields (user scope) | Per-user within a flow | Active session (must be explicitly reset) | GetState / SetState | Caching prompts, tracking conversation phase, follow-up counters |
State fields (agent scope) | Shared across all users within a flow | Agent lifetime (persists until modified) | GetState / SetState | Dynamic skill routing defaults, agent-wide configuration |
| Persona attributes | Individual end-user (persona) | Persists across sessions until deleted or reset | GetPersonaAttribute / SetPersonaAttribute | Extracted user info, booking history, conversation metadata |
| Customer attributes | Customer account (organization) | Permanent (agent configuration) | GetCustomerAttribute / SetCustomerAttribute | Business name, working hours, agent behavior settings |
| Local variables | Single skill execution | One skill invocation only | {% set var = ... %} | Intermediate calculations, temporary values within a skill |
| Skill parameters | Single skill invocation | One skill call | Declared in YAML, passed at call time | Input data from the calling skill or event payload |
When to use state vs persona attributes
State and persona attributes are both per-user storage, but they differ in scope and lifetime:
-
State fields are scoped to a single flow and are designed for intra-session coordination between skills. They reset when the session resets (if a reset skill is bound to
session_started). Use state for values that only matter during the active conversation: cached prompts, follow-up counters, conversation phase tracking. -
Persona attributes are global to the user and persist across sessions. They are accessible from any flow in any agent. Use persona attributes for values that should survive beyond the current conversation: extracted contact information, booking history, user preferences.
:::
โ ๏ธ CAUTION
State fields and persona attributes use different access commands. GetState(name="...") reads a flow state field, while GetPersonaAttribute(id=user_id, field="...") reads a persona attribute. Mixing them up will return empty values without an error.
:::
State lifecycle
Understanding when state is created, persists, and resets is critical for building reliable agent behavior.
Creation
State fields are initialized with their default_value when the flow is first loaded. This occurs during:
- Agent startup
- Project publishing
- Flow creation or modification
Persistence
State values persist across event cycles within the flow. A value written by one skill remains available to all subsequent skill executions in the same flow. For user-scoped fields, each user has their own isolated copy. For agent-scoped fields, a single shared copy exists.
Reset
State is not automatically reset between sessions. Flows that need clean state at the start of each conversation must explicitly reset their fields. The standard pattern is to bind a reset skill to the session_started and/or end_session events:
events:
- idn: session_started
skill_selector: skill_idn
skill_idn: ResetFollowUpStateSkill
state_idn: null
integration_idn: null
connector_idn: null
interrupt_mode: queue
- idn: end_session
skill_selector: skill_idn
skill_idn: ResetFollowUpStateSkill
state_idn: null
integration_idn: system
connector_idn: system
interrupt_mode: queueThe reset skill then writes default values to each field:
{{SetState(name="followup_counter", value="0")}}
{{SetState(name="event_block", value="False")}}
{{SetState(name="follow_up_state", value="active")}}:::
๐จ WARNING
If a flow does not include a reset skill bound to session_started, state values from the previous session will carry over. This can cause unexpected behavior -- for example, a follow-up counter that starts at 5 instead of 0, or a cached prompt that is stale from a previous conversation.
:::
Common state patterns summary
| Pattern | Description | Example flow |
|---|---|---|
| Prompt caching | Store compiled prompts in state to avoid recomputation on every event. | CAMainFlow (cached_base_prompt, fast_prompt) |
| Phase tracking | Use a state field as a lightweight state machine to track conversation phases. | CAFollowUpFlow (follow_up_state, chat_lifecycle_state) |
| Retry counting | Increment a counter in state on each attempt; reset after success or max retries. | CAScheduleFlow (retry_count) |
| Dynamic routing | Store a skill identifier in state, referenced by skill_idn_from_state event bindings. | CAMainFlow (phone_reply_skill, conversation_started_newo_voice_skill) |
| Event gating | Block event processing by setting a boolean state field that downstream skills check. | CAFollowUpFlow (event_block) |
| Result storage | Store LLM-generated content (scenarios, drafts) for iterative editing across turns. | ScenarioWriter (original_scenario, fixed_scenario, mode) |
| Session reset | Write default values to all state fields when a session starts or ends. | CAFollowUpFlow (ResetFollowUpStateSkill), CAMainFlow (ResetSessionStateSkill) |
| Waiting mode | Set a flag to change agent behavior while waiting for an external result. | CAMainFlow (waiting_mode_active, waiting_mode_footer_instruction) |
How state relates to other concepts
- Flows -- State fields are declared in the flow YAML and are scoped to a flow instance. Each flow manages its own independent state. See Flows for the full YAML structure.
- Skills -- Skills are the only entities that read and write state fields. See Skills for details on NSL execution and skill-to-skill communication.
- Events and the event system -- State enables dynamic event routing via
skill_idn_from_state. See Events and the event system for the full event processing lifecycle. - Attributes system -- State fields complement the broader attributes system. State is flow-scoped and session-oriented; attributes are entity-scoped and persistent. See Attributes system for the full attribute taxonomy.
Updated about 6 hours ago
