YAML Source is the Network Storage authoring standard.
Use the s&box Library Manager install for auto-updates. GitHub is best for agents, source review, contributions, and manual installs.

Workflows

Workflows are reusable internal flows. They are the smallest naming

# Workflows

Workflows are reusable internal flows. They are the smallest naming
unit for shared validation, computation, branching, writes, and
notifications that should be reused across multiple endpoints.

In v3, **workflows** and **internal endpoints** are two authoring
faces over the same reusable-flow runtime:

- **Workflow** = reusable flow addressed by `id`, usually modeled
with `params` and `returns`.
- **Internal endpoint** = endpoint resource with `exposure: internal`,
useful when you want endpoint-style `input` / `response` metadata
without exposing a public slug.
- **Public endpoint** = the HTTP-exposed boundary that game clients
call.

All three run through the same flow executor. Public requests
authenticate once at the boundary; nested `action: run` calls stay
in-process and reuse the same `steamId`, `playerKey`, secret-key
flags, and auth decisions.

## Workflow Source Format

Create new workflows in YAML Source mode:

```yml
sourceVersion: "1"
kind: workflow
id: validate_purchase
name: Validate Purchase
description: Shared purchase validation
params:
player:
type: object
description: Player data from a read step
item_id:
type: string
steps:
- id: item
type: lookup
source: values
table: shop_items
where:
field: item_id
op: "=="
value: "{{item_id}}"
- id: can_afford
type: condition
check:
field: "{{player.gold}}"
op: ">="
value: "{{item.cost}}"
onFail:
reject: true
errorCode: NOT_ENOUGH_GOLD
returns:
item: "{{item}}"
cost: "{{item.cost}}"
```

The same reusable logic can also be modeled as an internal
endpoint/flow when you want endpoint-style input/response fields
without exposing a public slug. Mark it internal-only and call it
from a route with `action: run`.

## Condition Syntax

### Single condition

```yml
left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
```

A single condition compares two values using an operator. Both sides can reference step results, input values, or literal values.

### Compound conditions (AND)

All conditions must be true:

```yml
all:
- left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
- left: "{{player.xp}}"
op: '>='
right: "{{item.xp_required}}"
```

### Compound conditions (OR)

At least one condition must be true:

```yml
any:
- left: "{{player.role}}"
op: ==
right: admin
- left: "{{player.role}}"
op: ==
right: moderator
```

### Nesting

Combine `all` and `any` for complex logic:

```yml
all:
- left: "{{player.xp}}"
op: '>='
right: 10000
- any:
- left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
- left: "{{player.has_coupon}}"
op: ==
right: true
```

This reads: "Player must have at least 10,000 XP AND must either have enough gold OR have a coupon."

## Operators

| Operator | Aliases | Description | Example |
|----------|---------|-------------|---------|
| `==` | `eq` | Equal | `"{{player.role}}" == "admin"` |
| `!=` | `neq`, `ne` | Not equal | `"{{player.status}}" != "banned"` |
| `>` | `gt` | Greater than | `"{{player.xp}}" > 10000` |
| `<` | `lt` | Less than | `"{{player.deaths}}" < 100` |
| `>=` | `gte`, `ge` | Greater than or equal | `"{{player.gold}}" >= "{{item.cost}}"` |
| `<=` | `lte`, `le` | Less than or equal | `"{{input.quantity}}" <= 99` |
| `contains` | `includes`, `has` | String/array contains | `"{{player.inventory}}" contains "skeleton_key"` |
| `in` | | Right-hand string/array contains the field value | `"{{input.statId}}" in ["power", "control"]` |
| `not_contains` | `not_includes`, `not_has`, `notcontains` | String/array does not contain | `"{{player.ownedVehicles}}" not_contains "voss_moonrider"` |
| `not_in` | `notin` | Right-hand string/array does not contain the field value | `"{{input.role}}" not_in ["banned", "muted"]` |
| `starts_with` | `startswith` | String starts with prefix | `"{{player.rank}}" starts_with "vip_"` |
| `not_starts_with` | `notstartswith` | String does not start with prefix | `"{{player.name}}" not_starts_with "bot_"` |
| `exists` | | Field is present and not null | `"{{player.guild}}" exists` |
| `not_exists` | `not_exist`, `notexists` | Field is absent or null | `"{{player.ban_reason}}" not_exists` |

Both symbol and word forms work interchangeably. For example, `op: neq` is identical to `op: "!="`.

Numeric comparisons (`>`, `<`, `>=`, `<=`) convert both sides to numbers before comparing. String comparisons (`==`, `!=`) compare as strings. The `contains` operator checks if the field string or array includes the value. The `in` operator checks if the value string or array includes the field. `not_contains` and `not_in` are their negated forms.

## Fail Actions

When a workflow's conditions evaluate to false, one or more fail actions execute. Each action is an independent toggle -- you can enable any combination.

### reject

Stops the endpoint pipeline and returns an error to the game client.

```yml
reject:
enabled: true
status: 403
error: NOT_ENOUGH_GOLD
message: You need {{item.cost}} gold but only have {{player.gold}}.
```

The `status` field sets the HTTP status code. The `error` and `message` fields are returned in the response body. Use template variables in the message to give players useful feedback.

### clamp

Instead of rejecting, adjusts the value to fit within valid bounds. The pipeline continues with the clamped value.

```yml
clamp:
enabled: true
field: xp
max: "{{values.combat.max_xp_per_action}}"
```

Clamping is useful for soft limits where you want to allow the action but cap the reward. For example, cap XP gains to prevent exploits without blocking the player entirely.

### flag

Marks the player for review. The flag appears on your dashboard with the workflow name, timestamp, and context.

```yml
flag:
enabled: true
reason: Attempted purchase without enough gold (had {{player.gold}}, needed {{item.cost}})
```

Flagging does not stop the pipeline unless `reject` is also enabled. Use flagging alone for soft monitoring -- let the action proceed but record that something suspicious happened.

### webhook

> **Webhook fail actions** are legacy condition-failure actions.
> Use them when a workflow rejection or clamp should raise an
> operational alert without changing the caller-visible response
> shape.

Sends a project-configured error webhook with the endpoint,
workflow, player, error code, and resolved message. This is useful
for Discord notifications, logging services, or custom alerting.

```yml
webhook:
enabled: true
severity: warning
```

Webhook fail actions are best-effort and asynchronous. They do not
block the endpoint response, and unreachable webhook destinations
are ignored after logging. Dry-run and validation flows can collect
the pending webhook payload without sending it. Use a dedicated
`webhook` step when the flow itself should send a concrete Discord
payload as part of normal execution.

### Combining actions

Enable multiple actions on the same workflow. For example, reject
the request AND flag the player AND send a Discord alert:

```yml
onFail:
reject:
enabled: true
status: 403
error: XP_TOO_LOW
flag:
enabled: true
reason: Attempted to access content requiring {{item.xp_required}} XP with only
{{player.xp}}
webhook:
enabled: true
severity: warning
```

## Route-Aware Reuse

Condition steps inside reusable flows can use explicit true/false
routes just like endpoint conditions. A `return` inside a reusable
flow returns to the caller; a `reject` aborts the top-level
endpoint request and discards pending writes.

Route-aware reusable flows inherit the caller's built-in identity
context. `steamId` and `playerKey` are protected runtime fields, so
a nested flow can read them but cannot overwrite them through
route params or returned values.

```yml
steps:
- id: can_afford
type: condition
check:
field: "{{player.gold}}"
op: ">="
value: "{{item.cost}}"
routes:
true: { action: continue }
false:
action: reject
status: 403
errorCode: NOT_ENOUGH_GOLD
message: Not enough gold.
returns:
cost: "{{item.cost}}"
```

## Hash-Based Versioning

Every workflow has a content hash that changes when you modify its conditions or actions. Endpoints reference workflows by ID, and the system records which version ran for each execution.

This means you can update a workflow's rules and know exactly when the new version started applying. The execution log shows the hash for every run:

```yml
workflowId: has_enough_gold
version: a3f8c2d1
result: pass
evaluatedAt: '2026-03-23T14:30:00.000Z'
```

When you view a workflow's history, you can see how results changed across versions -- useful for verifying that a rule change had the intended effect.

## Bindings

Bindings map workflow variables to values from your endpoint pipeline. When you bind a workflow to an endpoint, you define where each variable comes from.

```yml
workflowId: has_enough_gold
bindings:
player.gold: "{{player.gold}}"
item.cost: "{{item.cost}}"
```

The workflow itself uses generic variable names. The bindings connect those generic names to the specific step results in your endpoint. This lets you reuse the same workflow across multiple endpoints with different data sources.

For example, the "has_enough_gold" workflow can be bound to both a "purchase-item" endpoint and a "upgrade-weapon" endpoint, each mapping `item.cost` to a different lookup step.

## Built-In Templates

Network Storage includes pre-built workflow templates for common validation patterns. Use them as-is or customize them.

### Player is Admin

Checks if the player has an admin role. Useful for gating admin-only endpoints.

```yml
condition:
left: "{{player.role}}"
op: ==
right: admin
onFail:
reject:
enabled: true
error: NOT_ADMIN
status: 403
```

### Has Enough Gold

Verifies the player can afford a purchase.

```yml
condition:
left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
onFail:
reject:
enabled: true
error: NOT_ENOUGH_GOLD
message: Need {{item.cost}} gold, have {{player.gold}}.
```

### XP Requirement

Checks if the player has enough XP for a gated action. Use this instead of checking a stored "level" field -- XP is the source of truth.

```yml
condition:
left: "{{player.xp}}"
op: '>='
right: "{{required_xp}}"
onFail:
reject:
enabled: true
error: XP_TOO_LOW
message: You need {{required_xp}} XP for this action. You have {{player.xp}}.
flag:
enabled: true
reason: Player with {{player.xp}} XP attempted action requiring {{required_xp}}
XP
```

### Value Range Check

Rejects suspiciously high or low input values. Good for catching modified clients.

```yml
condition:
all:
- left: "{{input.quantity}}"
op: '>='
right: 1
- left: "{{input.quantity}}"
op: <=
right: 100
onFail:
reject:
enabled: true
error: INVALID_QUANTITY
status: 400
flag:
enabled: true
reason: Sent quantity {{input.quantity}} (expected 1-100)
```

## Execution Logging

Every workflow execution is logged with:

- **Workflow ID and version hash** -- which workflow ran, which version
- **Result** -- `pass` or `fail`
- **Evaluated conditions** -- what each side resolved to (e.g. `left: 500, op: >=, right: 800` resolved to `false`)
- **Actions taken** -- which fail actions fired (reject, clamp, flag, webhook)
- **Timestamp** -- when it ran
- **Endpoint context** -- which endpoint triggered it, which step

View execution logs from the dashboard to debug validation issues or audit workflow behavior. Logs are retained for 30 days.

Example log entry:

```yml
workflowId: has_enough_gold
version: a3f8c2d1
result: fail
condition:
left:
ref: player.gold
resolved: 500
op: '>='
right:
ref: item.cost
resolved: 800
actions:
- reject
- flag
endpoint: purchase-item
steamId: '76561198000001'
timestamp: '2026-03-23T14:30:00.000Z'
```

## Player Flagging System

When a workflow flags a player, the flag is stored in the project's flag log with:

- **Steam ID** -- which player was flagged
- **Workflow ID** -- which workflow triggered the flag
- **Reason** -- the evaluated reason string with resolved template variables
- **Endpoint** -- which endpoint was being called
- **Timestamp** -- when the flag was created
- **Input data** -- what the player sent in the request

View and manage flagged players from the dashboard. Flags accumulate -- a player flagged multiple times shows all incidents. Use this to identify patterns and take action (manual review, temporary bans, etc.).

Flags are different from rate limit violations. A rate limit blocks or clamps automatically. A flag is a record for human review.

## C# Example: Handling Workflow Rejections

```csharp
var data = await NetworkStorage.CallEndpoint( "purchase-item", new
{
itemId = "fire_staff"
} );

if ( !data.HasValue )
{
// The library logs endpoint errors. Show a generic failure state or retry UI.
Log.Warning( "Purchase failed." );
return;
}

// Success
var itemName = data.Value.Str( "itemName", "item" );
var remainingGold = data.Value.Int( "remainingGold", 0 );
Log.Info( $"Purchased {itemName}! Gold remaining: {remainingGold}" );
```

## Enhanced Multi-Step Workflows

Workflows can contain multiple steps, accept typed parameters, and return values. This allows you to encapsulate reusable validation logic -- such as "validate a purchase" -- into a single workflow that multiple endpoints can call.

Legacy condition-only workflows (as documented above) continue to work exactly as before. Enhanced workflows are a superset of the original format.

### Definition Format

```yml
id: validate_purchase
name: Validate Purchase
params:
player:
type: object
description: Player data from a read step
item_id:
type: string
description: ID of item to buy
item_table:
type: string
description: Game values table to look up
owned_field:
type: string
description: Array field path on player for ownership check
steps:
- id: item
type: lookup
source: values
table: "{{item_table}}"
where:
field: id
op: ==
value: "{{item_id}}"
- id: not_owned
type: condition
check:
field: "{{player.{{owned_field}}}}"
op: not_contains
value: "{{item_id}}"
onFail:
reject: true
errorCode: ALREADY_OWNED
- id: can_afford
type: condition
check:
field: "{{player.currency}}"
op: '>='
value: "{{item.cost}}"
onFail:
reject: true
errorCode: NOT_ENOUGH_CURRENCY
returns:
item: "{{item}}"
cost: "{{item.cost}}"
```

### Parameters

The `params` object defines the inputs the workflow accepts. Each parameter has a `type` and optional `description`. When an endpoint calls the workflow, it passes values for each parameter:

```yml
id: validate
type: workflow
workflow: validate_purchase
params:
player: "{{player}}"
item_id: "{{input.vehicle_id}}"
item_table: vehicles
owned_field: ownedVehicles
```

Parameter values can be template references (`{{player}}`) or literal values (`"vehicles"`). Inside the workflow, steps reference params by name -- for example, `{{item_id}}` resolves to whatever the caller passed for `item_id`.

### Return Values

The `returns` object defines what values are exposed back to the calling endpoint. After the workflow step completes, return values are available under the step ID:

```yml
# If the workflow step id is "validate" and returns item/cost,
# subsequent endpoint steps can use:
value: "{{validate.item}}"
value: "{{validate.cost}}"
```

If no `returns` object is specified, all step results from the workflow are exposed under the step ID.

### Allowed Step Types

Workflows support all step types available in endpoints:

| Step Type | Description |
|-----------|-------------|
| `read` | Load player data from a collection |
| `lookup` | Table row lookups |
| `lookup_many` | Load several rows from one source |
| `filter` | Multi-row queries |
| `random_select` | Filter records and pick one at random |
| `transform` | Computed values |
| `compute` | Several computed values in one step |
| `random` | Secure server-side RNG |
| `condition` | Validation checks |
| `write` | Modify collection data |
| `delete` | Remove records |
| `workflow` | Nested workflow calls |

**Write and delete steps in workflows are deferred.** They are
queued alongside the parent endpoint's writes and only execute
after all steps in the entire pipeline pass. This preserves
atomicity -- if any condition fails anywhere in the pipeline (in
the endpoint or in a nested workflow), no data is written.

This means you can build complex workflows that read data, validate
conditions, and write results, all as a single reusable unit. The
calling endpoint does not need to handle the write logic -- the
workflow encapsulates it.

### Limits

> **Reusable-flow limits** apply across the whole top-level execution, not per file.

- **Max nested flow depth:** 4
- **Max read-like steps:** 25 across the endpoint plus all nested
reusable flows
- **Max retry attempts per step:** 10
- **Default max step visits without retry bounds:** 20
- **Max route transitions per execution:** 1000
- **Max wall time:** 30000ms total, with each `sleep` /
`retry.sleepMs` capped at 1000ms

### Example: Workflow with Read, Validate, and Write

A workflow that handles a complete purchase flow -- reads the
player, validates affordability, deducts currency, and adds the
item to inventory:

```yml
id: do_purchase
name: Do Purchase
params:
item_table:
type: string
description: Game values table to look up
item_id:
type: string
description: ID of item to buy
steps:
- id: player
type: read
collection: player_data
key: "{{playerKey}}"
- id: item
type: lookup
source: values
table: "{{item_table}}"
where:
field: id
op: ==
value: "{{item_id}}"
- id: can_afford
type: condition
check:
field: "{{player.currency}}"
op: '>='
value: "{{item.cost}}"
onFail:
reject: true
errorCode: NOT_ENOUGH_CURRENCY
errorMessage: Need {{item.cost}}, have {{player.currency}}
- id: pay
type: write
collection: player_data
key: "{{playerKey}}"
ops:
- op: inc
path: currency
value: "{{-item.cost}}"
source: purchase
reason: Bought {{item_id}}
- op: push
path: inventory
value: "{{item_id}}"
returns:
item: "{{item}}"
cost: "{{item.cost}}"
```

The endpoint that calls this workflow only needs one step:

```yml
steps:
- id: purchase
type: workflow
workflow: do_purchase
params:
item_table: weapons
item_id: "{{input.weapon_id}}"
response:
status: 200
body:
bought: "{{purchase.item}}"
cost: "{{purchase.cost}}"
```

The write step inside the workflow is deferred -- if the condition fails, no currency is deducted and no item is added. The workflow handles the complete purchase atomically.

### Example: Reusing Across Endpoints

The same `validate_purchase` workflow can validate vehicle purchases and upgrade purchases by passing different parameters:

```yml
# buy-vehicle endpoint
id: validate
type: workflow
workflow: validate_purchase
params:
player: "{{player}}"
item_id: "{{input.vehicle_id}}"
item_table: vehicles
owned_field: ownedVehicles

# buy-upgrade endpoint
id: validate
type: workflow
workflow: validate_purchase
params:
player: "{{player}}"
item_id: "{{input.upgrade_id}}"
item_table: upgrades
owned_field: ownedUpgrades
```

Both endpoints get the same validation logic (item exists, not already owned, can afford) without duplicating condition steps.