Skill scripts and the Newo Script Language

Every skill in the Newo Agent Framework executes a script written in one of two templating languages: the Newo Script Language (NSL), a Jinja2-based language stored in .nsl files, or the legacy Guidance format, a Handlebars-like language stored in .nslg files. This article is a comprehensive reference for both syntaxes, covering control structures, role tags, LLM generation, action calling, and real-world patterns.

NSL overview

NSL (Newo Script Language) is the primary scripting language for Newo agent skills. It is built on Jinja2 templating and extends it with platform-specific actions, role tags for LLM prompting, and access to Python standard library modules like json, re, and datetime.

NSL scripts are stored in .nsl files and executed by the nsl runner type. The language serves two purposes simultaneously:

  • Logic execution --- Variable assignment, conditionals, loops, action calls, and data manipulation
  • Prompt construction --- Building system/user/assistant messages that are sent to an LLM via Gen() or GenStream()

Syntax reference

NSL uses Jinja2's delimiter system. The table below summarizes the four delimiter types and their purposes.

DelimiterPurposeExample
{% ... %}Control blocks (set, if, for, macro){% set user_id = GetUser().id | string %}
{{ ... }}Expressions and action calls{{SendSystemEvent(eventIdn="extend_session", connectorIdn="system")}}
{# ... #}Comments (stripped from output){# Define user's phone number in background #}
{{tag}}...{{end}}Role tags for LLM message sections{{system}}You are a helpful assistant.{{end}}

Variable assignment with set

Variables are assigned using {% set %} blocks. The value can be a literal, a function call, a filter chain, or a complex expression:

{% set user_id = GetUser().id | string %}
{% set current_datetime = GetDateTime(format="datetime", timezone=project_business_time_zone, weekday=True) %}
{% set schema = {
  "type": "object",
  "properties": {
    "revenue_model_key": {
      "type": "string",
      "enum": revenueModels | list
    }
  },
  "required": ["revenue_model_key"]
} %}

Variables can hold strings, numbers, booleans, lists, dictionaries, and objects returned by platform functions.

Conditionals with if/elif/else

NSL supports full Jinja2 conditional blocks:

{% if GetTriggeredAct().arguments.isSharedPhoneNumber == "true" %}
    {{SetPersonaAttribute(id=user_id, field="temporal", value="True")}}
{% else %}
    {% set detected_phone_number_with_country_code = GetActor().externalId | string %}
    {{SetPersonaAttribute(id=user_id, field="phone_number", value=detected_phone_number_with_country_code)}}
{% endif %}

Conditions can use standard comparison operators (==, !=, >, <), the not keyword, the in operator for membership testing, and logical operators and and or:

{% if not transcript.strip() or (not GetUser().name ~ ":" in transcript) %}
    {{Return()}}
{% endif %}

Loops with for

{% for %} blocks iterate over sequences. Inside a loop, the loop body can modify outer-scope variables (see Variable scoping below):

{% set valid_bookings = [] %}

{% for booking in bookings %}
    {% set booking_date_time = booking.datetime %}
    {% if datetime.datetime.strptime(booking_date_time, "%Y-%m-%d %H:%M").date() >= datetime.datetime.strptime(current_datetime, "%Y-%m-%d").date() %}
        {{ valid_bookings.append(booking) }}
    {% endif %}
{% endfor %}

Comments

Comments are enclosed in {# ... #} and are completely stripped from the script output. They do not reach the LLM or appear in any rendered text:

{# TODO: Define whether detected_phone_number must be reset on conversation_ended or not #}
{# Perform semaphore analysis #}

Multi-line set blocks

For assigning long multi-line strings (such as prompt instructions), NSL supports the {% set variable %}...{% endset %} block form:

{%- set follow_up_instruction %}
This is a **follow-up check-in** turn. Your sole goal is to verify the user is still connected.

**DO**
- Keep the user engaged with a brief check-in question.

**DON'T**
- Don't proceed with operational steps.
{%- endset %}

Variable scoping

NSL has a distinctive scoping behavior that differs from standard Jinja2: variables set inside inner blocks (such as if or for) are visible in the outer scope after the block ends. This means a variable assigned inside a conditional branch or a loop iteration remains accessible after the block closes.

This behavior is used throughout the codebase. For example, the pattern of building a list inside a for loop by calling .append() on an outer-scope list works because the list reference is shared:

{% set email_actors = [] %}
{% for actor in system_actors %}
    {% if actor.externalId.startswith("email_actor_") %}
        {{email_actors.append(actor)}}
    {% endif %}
{% endfor %}
{# email_actors is populated here with all matching actors #}

Similarly, variables set inside if blocks are available afterwards:

{% if base_instruction.strip() %}
    {% set fast_prompt = prompt_fast_compile(user_id=user_id, order="before_gen", conversation_channel=conversation_channel, base_instruction=base_instruction) %}
{% else %}
    {% set fast_prompt = GetState(name="fast_prompt") %}
{% endif %}
{# fast_prompt is accessible here regardless of which branch ran #}

::: ⚠️ CAUTION Because inner-scope variables leak into the outer scope, be careful with variable naming inside loops and conditionals. Reusing a variable name in a nested block will overwrite the outer value. :::

Filters

NSL supports standard Jinja2 filters via the pipe (|) operator. Filters transform a value and can be chained:

{% set user_id = GetUser().id | string %}
{% set actors = actors | map(attribute='id') | map('string') | list %}
{% set revenue_models = revenueModels | list %}

Common filters used in NSL scripts:

FilterDescriptionExample
stringConverts a value to its string representationGetUser().id | string
intConverts a string to an integerGetState(name="followup_counter") | int
listConverts an iterable to a listrevenueModels | list
map(attribute=...)Extracts an attribute from each item in a sequenceactors | map(attribute='id')
map('filter')Applies a filter to each itemactors | map('string')
lengthReturns the length of a sequence or stringactors | length

NSL also provides access to Python string methods directly on string values (e.g., .strip(), .startswith(), .replace(), .lower()). These are not Jinja2 filters but are available because NSL exposes Python string objects:

{% set cleaned_prompt = re.sub("<\\|\\|.*?\\|\\|>", "", fast_prompt.strip()) %}
{% set conversation_meta = GetPersonaAttribute(id=user_id, field="conversation_meta").strip() %}
{% set sms_value = (sms_subscribed | string).lower() %}

Role tags for LLM prompting

When a skill needs to call an LLM, the script constructs a prompt using role tags. These tags define the message sections (system, user, assistant) that are sent to the LLM as part of the chat completion request.

NSL role tags

NSL uses a double-brace tag syntax without the Jinja2 block delimiters:

TagCloses withPurpose
{{system}}{{end}}Opens a system message section
{{user}}{{end}}Opens a user message section
{{assistant}}{{end}}Opens an assistant message section

A typical LLM-calling skill places the system prompt between {{system}} and {{end}}, then opens an {{assistant}} block where Gen() or GenStream() produces the response:

{{system}}
Translate a text specified in <TextToTranslate> to {{target_language}} language.
Return only the translated text without any explanation or additional text.

<TextToTranslate>
{{input_text}}
</TextToTranslate>
{{end}}

{{assistant}}
  {% set translated_text = Gen(temperature=0.1, topP=0, maxTokens=4000, skipFilter=True, thinkingBudget=420) %}
{{end}}

{{Return(val=translated_text)}}

::: 🗒️ NOTE Text placed between a role tag and its closing {{end}} becomes the content of that message role in the LLM request. Jinja2 expressions and control blocks inside role-tagged sections are evaluated before the prompt is sent, so variables and function calls resolve to their values. :::

Inline role tag pattern

Many skills condense the role tags onto a single line for compact structured generation:

{{system}}{{prompt.strip()}}{{end}}{{assistant}}{% set result_json = Gen(jsonSchema=schema, validateSchema="True", temperature=0.2, topP=0, maxTokens=4000, skipFilter=True, thinkingBudget=425) %}{{end}}

{{Return(val=result_json)}}

Multi-section prompts

More complex skills build multi-section prompts with extensive system instructions:

{{system}}
You are the call center supervisor agent. You are analyzing a session.

<Scenarios>
{{scenarios}}
</Scenarios>

<ConversationHistory>
{{transcript}}
</ConversationHistory>
{{end}}
{{assistant}}{% set result_json = Gen(jsonSchema=schema, validateSchema="True", temperature=0.2, topP=0, maxTokens=4000, skipFilter=True, thinkingBudget=1020) %}{{end}}

{{Return(val=result_json)}}

Action calling from NSL scripts

NSL scripts interact with the Newo platform through actions --- built-in functions that read or write platform state, communicate with integrations, trigger events, and control execution flow. Actions are called using Jinja2 expression syntax ({{ }}).

Platform data actions

These actions read from and write to the platform's data stores:

ActionDescriptionExample
GetUser()Returns the current user object{% set user_id = GetUser().id | string %}
GetActor()Returns the current actor objectGetActor().externalId
GetActors(...)Queries actors by persona, integration, or connectorGetActors(personaId=user_id, integrationIdn="vapi")
GetTriggeredAct()Returns the act (event payload) that triggered this skillGetTriggeredAct().arguments.sectionName
GetState(name=...)Reads a flow state field valueGetState(name="user_reply_buffer")
SetState(name=..., value=...)Writes a flow state field value{{SetState(name="waiting_mode_active", value="False")}}
GetPersonaAttribute(id=..., field=...)Reads a persona attributeGetPersonaAttribute(id=user_id, field="email")
SetPersonaAttribute(id=..., field=..., value=...)Writes a persona attribute{{SetPersonaAttribute(id=user_id, field="full_name", value=user_name)}}
GetCustomerAttribute(field=...)Reads a customer (project-level) attributeGetCustomerAttribute(field="project_business_time_zone")
GetSessionInfo()Returns current session metadataGetSessionInfo().id | string
GetDateTime(...)Returns the current date/time in a given format and timezoneGetDateTime(format="datetime", timezone=tz, weekday=True)
GetMemory(...)Retrieves conversation memoryGetMemory(count=10, maxLen=5000, filterByActorIds=actors)

Event and command actions

These actions communicate with other agents and integrations:

ActionDescription
SendSystemEvent(eventIdn=..., connectorIdn=..., ...)Fires a system event with optional key-value arguments
SendCommand(commandIdn=..., integrationIdn=..., connectorIdn=..., ...)Sends a command to an integration connector
UpdateUser(field=..., value=...)Updates a field on the current user object

Control flow actions

ActionDescription
Return()Exits the current skill immediately with no return value
Return(val=...)Exits the current skill and returns a value to the caller

LLM generation actions

ActionDescription
Gen(...)Synchronous LLM generation; returns the complete response as a string
GenStream(...)Streaming LLM generation; streams tokens to specified actors in real time

Calling other skills

Skills can call other skills defined in the same flow as if they were functions. The called skill's idn becomes the function name, and parameters are passed as keyword arguments:

{% set memory = get_memory(user_id=user_id, count="10", include_system="True") %}
{% set section = build_custom_section(sectionName=section_name, sectionValue=section_value) %}
{{log_event(message="New conversation started.")}}

The call is synchronous: the calling skill pauses until the sub-skill completes and returns a value.

Python standard library access

NSL scripts have access to several Python standard library modules, which are used directly in expressions:

ModuleCommon usage
jsonjson.loads(...), json.dumps(..., ensure_ascii=False, indent=2)
rere.sub(pattern, replacement, string, flags=re.DOTALL)
datetimedatetime.datetime.strptime(date_string, format)

Example using json and re together:

{% set pre_analysis = json.loads(arguments.preAnalysis) %}
{% set cleaned = re.sub('<' ~ section_name ~ '>.*?</' ~ section_name ~ '>\\s*---\\s*', '', current_custom_prompt_section, flags=re.DOTALL) %}
{{Return(val=json.dumps({"date_added": current_datetime, "content": summary}, ensure_ascii=False, indent=2))}}

Real code examples

Example 1: Structured generation skill

This reusable skill accepts a prompt and a JSON schema, sends them to the LLM, and returns the structured result:

{{system}}{{prompt.strip()}}{{end}}{{assistant}}{% set result_json = Gen(
    jsonSchema=schema,
    validateSchema="True",
    temperature=0.2,
    topP=0,
    maxTokens=4000,
    skipFilter=True,
    thinkingBudget=425
) %}{{end}}

{{Return(val=result_json)}}

Example 2: Streaming reply generation

This skill streams a conversational reply directly to a user-facing actor:

{{system}}{{prompt.strip()}}{{end}}{{assistant}}{% set agent_answer = GenStream(
    interruptMode="interruptWindow",
    interruptWindow=0.7,
    temperature=0.2,
    topP=0.5,
    maxTokens=4000,
    skipFilter=True,
    sendTo="actors",
    actorIds=[actor_id],
    thinkingBudget=thinking_budget
) %}{{end}}

{{Return(val=agent_answer)}}

Example 3: Data processing with conditionals and loops

This skill filters a list of bookings to find only future, valid ones:

{% set cancellation_type = GetCustomerAttribute(field="project_attributes_setting_booking_cancellation_type") %}
{% set bookings = json.loads(bookings) %}
{% set valid_bookings = [] %}
{% set current_datetime = GetDateTime(timezone=GetCustomerAttribute(field="project_business_time_zone"), format="date") %}

{% for booking in bookings %}
    {% set booking_date_time = booking.datetime %}
    {% if datetime.datetime.strptime(booking_date_time, "%Y-%m-%d %H:%M").date() >= datetime.datetime.strptime(current_datetime, "%Y-%m-%d").date() %}
        {% if cancellation_type == "default" %}
            {% if booking.meta.manage_booking_url %}
                {{ valid_bookings.append(booking) }}
            {% endif %}
        {% else %}
            {{ valid_bookings.append(booking) }}
        {% endif %}
    {% endif %}
{% endfor %}

{{ Return(val=json.dumps(valid_bookings, ensure_ascii=False, indent=2)) }}

Example 4: Conversation initialization skill

This skill sets up state when a new conversation begins, demonstrating event firing, conditional branching, and sub-skill calls:

{{SendSystemEvent(eventIdn="build_operating_phrase", connectorIdn="system")}}
{{SendSystemEvent(eventIdn="prepare_injecting_data", connectorIdn="system", global="True")}}
{{SetState(name="user_reply_buffer", value=" ")}}
{{SetState(name="corrected_memory", value=" ")}}
{% set user_id = GetUser().id | string %}

{% if is_feature_active(featureFlagName="multi_location") %}
    {{SetPersonaAttribute(id=user_id, field="multi_location_current_location", value="default")}}
{% endif %}

{{update_conversation_meta(userId=user_id)}}

{% if GetTriggeredAct().arguments.isSharedPhoneNumber == "true" %}
    {{SetPersonaAttribute(id=user_id, field="temporal", value="True")}}
{% else %}
    {% set detected_phone_number_with_country_code = GetActor().externalId | string %}
    {{SetPersonaAttribute(id=user_id, field="phone_number", value=detected_phone_number_with_country_code)}}

    {# Define user's phone number in background #}
    {{SendSystemEvent(
        eventIdn="define_user_phone_number",
        connectorIdn="system",
        user_id=user_id,
        user_phone_number_with_country_code=detected_phone_number_with_country_code
    )}}
{% endif %}

{% set project_business_time_zone = GetCustomerAttribute(field="project_business_time_zone") %}
{% set current_datetime = GetDateTime(format="datetime", timezone=project_business_time_zone, weekday=True) %}

{{initialize_session(user_id=user_id)}}
{{log_event(message="New conversation started.")}}
{{SendSystemEvent(eventIdn="extend_session", connectorIdn="system")}}

Example 5: Boolean logic skill

This small skill evaluates business logic and returns a simple result:

{% set is_new_customer = not session.existing_customer %}
{% set is_not_dental = industry != "dental_industry" %}
{% set is_valuable = is_new_customer or is_not_dental %}
{% set is_lead = is_valuable and (session.is_lead or ("[L]" in intent_name)) %}

{% if is_lead %}
    {{Return(val="true")}}
{% else %}
    {{Return(val="false")}}
{% endif %}

Legacy Guidance V1 format

The Guidance format is the original scripting language for Newo skills. It uses Handlebars-like syntax and is stored in .nslg files. While NSL is the preferred language for new skills, many existing skills still use Guidance, and both formats coexist within the same agent.

Guidance syntax reference

SyntaxPurposeNSL equivalent
{{Set(name="x", value=...)}}Variable assignment{% set x = ... %}
{{#if condition}}...{{/if}}Conditional block{% if condition %}...{% endif %}
{{#if condition}}...{{else}}...{{/if}}If/else{% if condition %}...{% else %}...{% endif %}
{{#system~}}...{{~/system}}System prompt section{{system}}...{{end}}
{{#assistant~}}...{{~/assistant}}Assistant response section{{assistant}}...{{end}}
{{!-- comment --}}Block comment{# comment #}
{{! comment }}Inline comment{# comment #}
Concat(a, b, c)String concatenationa ~ b ~ c (tilde operator)
IsEmpty(text=...)Check if a string is emptynot value.strip()
Stringify(...)Convert to stringvalue | string
GetValueJSON(obj=..., key=...)Extract a key from JSONjson.loads(obj)[key]
StartNotInterruptibleBlock()Begin non-interruptible section(same in NSL)
StopNotInterruptibleBlock()End non-interruptible section(same in NSL)

Guidance role tags

Guidance uses a different syntax for role tags, with block-style delimiters and tilde (~) whitespace control:

{{#system~}}
You are a helpful assistant. Summarize the following conversation.

<Conversation>
{{transcript}}
</Conversation>

Summary:
{{~/system}}
{{#assistant~}}
{{Return(
  val=Gen(temperature=0.1, topP=0, skipFilter=True, thinkingBudget=1020)
)}}
{{~/assistant}}

The tilde (~) adjacent to the opening or closing tag strips whitespace on that side, keeping the prompt compact.

Guidance code example: conversation started skill

This Guidance skill handles conversation initialization, showing Set(), #if, and event dispatch:

{{StartNotInterruptibleBlock()}}

{{Set(name="user_id", value=GetUser(field="id"))}}
{{Set(name="current_actor_integration_idn", value=GetActor(field="integrationIdn"))}}

{{!-- TODO: Refactor, split and speed up Conversation Started Skill --}}
{{#if current_actor_integration_idn == "vapi"}}
    {{SetCustomerAttribute(field="project_attributes_setting_voice_integration_service", value="VAPI Integration")}}
{{/if}}

{{SetState(name="user_reply_buffer", value=" ")}}
{{SetState(name="corrected_memory", value=" ")}}

{{#if is_feature_active(featureFlagName="multi_location")}}
    {{SetPersonaAttribute(id=user_id, field="multi_location_current_location", value="default")}}
{{/if}}

{{SendSystemEvent(eventIdn="build_operating_phrase", connectorIdn="system")}}
{{SendSystemEvent(eventIdn="prepare_injecting_data", connectorIdn="system", global=True)}}

{{StopNotInterruptibleBlock()}}

Guidance code example: call transfer skill

This Guidance skill demonstrates deeper conditional logic, string concatenation with Concat(), and command dispatch:

{{Set(name="transfer_call_phone_number", value=Stringify(
    GetValueJSON(obj=transfer_call_phone_number_and_clarification_instruction, key="transfer_call_phone_number")
))}}

{{#if transfer_call_phone_number == "null"}}
    {{Set(name="clarification_instruction", value=Stringify(
        GetValueJSON(obj=transfer_call_phone_number_and_clarification_instruction, key="clarification_instruction")
    ))}}

    {{log_event(message=Concat(
        "The command to transfer the call was not executed. Phone number not determined. Clarification: '",
        clarification_instruction, "'"
    ))}}

    {{Return()}}
{{/if}}

{{#if voice_integration_service == "NEWO Voice Integration"}}
    {{SendCommand(
        commandIdn="transfer_call",
        integrationIdn="newo_voice",
        connectorIdn="newo_voice_connector",
        phoneNumber=transfer_call_phone_number,
        waitForTransferSeconds=transfer_call_timeout
    )}}
{{/if}}

NSL vs Guidance comparison

The following side-by-side comparison shows how equivalent logic looks in each format.

Variable assignment:

NSL (.nsl)Guidance (.nslg)
{% set user_id = GetUser().id | string %}{{Set(name="user_id", value=GetUser(field="id"))}}

Conditional:

NSL (.nsl)Guidance (.nslg)
{% if flag == "True" %}...{% endif %}{{#if flag == "True"}}...{{/if}}

LLM prompt construction:

NSL (.nsl)Guidance (.nslg)
{{system}}...{{end}}{{#system~}}...{{~/system}}
{{assistant}}...{{end}}{{#assistant~}}...{{~/assistant}}

LLM generation call:

NSL (.nsl)Guidance (.nslg)
{% set result = Gen(temperature=0.2) %}{{Set(name="result", value=Gen(temperature=0.2))}}

String concatenation:

NSL (.nsl)Guidance (.nslg)
"Hello " ~ user_name ~ "!"Concat("Hello ", user_name, "!")

Comments:

NSL (.nsl)Guidance (.nslg)
{# This is a comment #}{{!-- This is a comment --}} or {{! comment }}

Runner type and file extension matching

Each skill in a flow YAML specifies a runner_type that must match the file extension of the referenced prompt_script. The flow also declares a default_runner_type that applies when a skill does not specify its own.

runner_typeFile extensionLanguage
nsl.nslNewo Script Language (Jinja2-based)
guidance.nslgGuidance V1 (Handlebars-based)

Example from a flow YAML showing both runner types in the same flow:

skills:
  - idn: HandleUserMessageSkill
    prompt_script: flows/MyFlow/skills/HandleUserMessageSkill.nsl
    runner_type: nsl
    model:
      model_idn: gemini25_flash
      provider_idn: google
    parameters: []

  - idn: ConversationStartedSkill
    prompt_script: flows/MyFlow/skills/ConversationStartedSkill.nslg
    runner_type: guidance
    model:
      model_idn: gemini25_flash
      provider_idn: google
    parameters: []

# ...

default_runner_type: guidance

::: ‼️ IMPORTANT The runner_type and file extension must always match. Setting runner_type: nsl for a .nslg file (or vice versa) will cause a script execution error. When adding a new skill, verify both the file extension and the runner_type field in the flow YAML. :::

Output expressions and text flow

Understanding how text moves through an NSL script is important for writing effective prompts.

Text that reaches the LLM

Any text placed between role tags ({{system}}...{{end}}, {{assistant}}...{{end}}) becomes part of the LLM prompt. This includes:

  • Literal text --- Written directly between the tags
  • Resolved expressions --- {{ variable }} or {{ function() }} expressions that produce string output
  • Whitespace --- Indentation and newlines between tags are preserved in the prompt

For example, in this script, the entire block between {{system}} and {{end}} becomes the system message, with {{scenarios}} and {{transcript}} replaced by their runtime values:

{{system}}
You are analyzing a session.

<Scenarios>
{{scenarios}}
</Scenarios>

<ConversationHistory>
{{transcript}}
</ConversationHistory>
{{end}}

Text that does not reach the LLM

  • {% %} control blocks --- set, if, for, and other control structures produce no output themselves
  • {# #} comments --- Entirely stripped
  • Action calls outside role tags --- Expressions like {{SetState(...)}} or {{SendSystemEvent(...)}} execute their side effects but their return values (usually None) do not contribute meaningful text

The Return() action

Return() ends skill execution and optionally passes a value back to the caller. It can appear anywhere in the script:

{# Early return with no value #}
{% if not transcript.strip() %}
    {{Return()}}
{% endif %}

{# Return with a computed value #}
{{Return(val=json.dumps(valid_bookings, ensure_ascii=False, indent=2))}}

When Return(val=...) is used, the returned value is available to the calling skill as the result of the sub-skill function call.

Cross-references

  • See Skills for the complete skill anatomy, YAML configuration, parameter passing, and execution lifecycle
  • See Flows for how skills are organized within flows and connected to events
  • See Events and the event system for details on SendSystemEvent and event dispatching
  • See Attributes system for the GetCustomerAttribute, GetPersonaAttribute, and SetPersonaAttribute functions referenced throughout NSL scripts
  • See Actors personas and users for the GetUser(), GetActor(), and GetActors() functions