On Hold's story toolkit connects a bounded conversation graph to owned variables, relationships, recurring customer records, mail, follow-up calls and campaign endings. It is a data language with explicit limits. It does not evaluate JavaScript or let a creator write arbitrary properties on the game object.

Start with a playable ordinary call, then add a graph. Studio's Insert branching call template creates a working graph example. Stable graph IDs matter as much as call IDs: saves and recordings refer to the node/choice identity. Review Content reference for the base scenario fields and Recording names before publishing.

A graph follows the normal call lifecycle

A scenario with dialogue still has its normal ID, voice, category, label, weight, minimum day, mood and opener. Its solutions array may be empty because a terminal graph node/choice supplies the resolution quality. The call still enters ordinary answer, verification, transcript, resolved-call and wrap-up behavior. A graph is not a separate fullscreen story engine.

dialogue.start names a node in dialogue.nodes. Each node key is a stable portable identifier. Nodes can gate execution with when and use elseNext for the other branch. The engine bounds graph traversal to 128 transitions so an accidental loop cannot run forever. Provide an explicit exit for meaningful branches rather than relying on that emergency limit.

The six node types

Node type Required/important fields Behavior
choice (also the default) text, 1–8 options Plays a caller line and presents the agent's choices.
say text, next Plays a caller line, then moves on.
diagnose next, optional text Reveals the already selected diagnostic cause. Requires scenario diagnosis.
applyEffect effects, next Applies bounded owned effects and continues.
queueFollowup scenarioId, dayOffset, next Schedules a later-day call and continues.
finish Optional quality Resolves normally, using best, good or ok.

Node text is at most 4,000 characters. A graph has at most 64 nodes. next and elseNext, when supplied, must name nodes in the same graph. A say node cannot omit its text; an effect or follow-up node cannot omit its continuation.

A choice option has stable id, agent text up to 1,000 characters, optional caller reply up to 4,000 characters, optional when and effects, and a next node or terminal resolve quality. Option IDs are unique within their node. Prefer IDs that are distinct throughout the pack when inspecting persistent choice.ID history; do not reuse a generic yes across unrelated story decisions if you need to distinguish them later.

Keep branches short enough to remain a support conversation. Test the unavailable-option case, every terminal quality, and a save/resume after a choice. When a condition depends on account verification, the player must have a clear route to verify through the normal CRM interface.

Conditions: exact comparisons

A condition may be true, false, a comparison, {all:[...]}, {any:[...]} or {not:...}. Omitted when means unconditional. all means every child must pass; any means at least one passes. A compound list contains at most 16 conditions and nesting stays below the depth-eight boundary.

This is a condition fragment for a helpful, verified response:

json
{
  "all": [
    {"field": "verified", "op": "==", "value": true},
    {"field": "relationship.mira", "op": ">=", "value": 10},
    {"not": {"field": "var.closed", "op": "==", "value": true}}
  ]
}

Operators are ==, !=, >, >=, <, <=, includes. Equality is strict: boolean true differs from string "true". Numeric comparisons require a numeric actual value. includes searches a string for a string; it is not an array-membership operator. Comparison values are finite numbers, booleans or strings.

Field group Available names
Session/day/outcome day, result, scenarioId, choiceId
Performance/resources csat, resolved, taken, stress, strikes, score, waves, bank
Current call checks verified, diagChecked, diagnosed, diagCause
Conversation history previousChoice, choiceCount, choice.CHOICE_ID
Owned state var.KEY, relationship.CHARACTER_ID

var.KEY reads this pack's variable and defaults to 0 when absent. Relationship values also default to 0. choice.CHOICE_ID is a boolean indicating whether that choice has been made in this pack. Context fields are relevant to their event: a day-start rule should not depend on a current call's diagnosis. Initialize important variables explicitly to remove ambiguity between an absent value, zero and false.

Owned character/scenario references are scoped by the loader. Use a local character ID such as mira in your authored relationship condition. No arbitrary nested path, method call or expression is accepted: game.bank, customer.address.length and computed functions are outside the language.

Effects: what a story can change

Each rule or choice can have at most 16 effects. The following are effect fragments; they belong in an effects array:

json
[
  {"type": "set", "key": "promised", "value": true},
  {"type": "increment", "key": "helped", "value": 1},
  {"type": "relationship", "characterId": "mira", "value": 10},
  {"type": "followup", "scenarioId": "RETURN_CALL", "dayOffset": 1}
]
Effect Fields and limits
set key, scalar value. Creates/updates this pack's variable.
increment key, finite value with magnitude ≤10,000. Stored totals clamp to ±1,000,000.
relationship characterId, finite delta with magnitude ≤100. Affinity clamps to −100…100.
mail Literal from, subj, body, optional bounded choices.
followup scenarioId, integer dayOffset 1–30. Queues a later-day call.
ending endingId identifying a declared campaign ending.
trophy achievementId belonging to a declared local trophy in this pack.
money Finite delta with magnitude ≤10,000.
stress Finite delta with magnitude ≤100.

Mail sender/subject have a 300-character bound; body has a 10,000-character bound. Keys and rule references use the approved portable key syntax, with reserved object keys forbidden. There is no arbitrary inventory item, remote request, file write or direct Steam achievement effect.

Follow-ups are queued for a real later authored/career day, not an immediate asynchronous callback. The referenced scenario must be available when that day runs. If a campaign ends today or has no later chapter, a dayOffset: 1 promise cannot be fulfilled by a chapter that never exists. Build a follow-up call definition and test its actual scheduling rather than assuming the rule merely naming it is sufficient.

Event rules and ordering

A rule is {id, on, when?, effects, once?}. Up to 64 rules belong to an experience definition. Supported events are dayStart, callEnded, choice, dayEnd and mailChoice. Rule IDs are unique and stable.

Rules run once by default. Set once: false only when you want repeated eligible events to trigger the rule. A once-only bonus should not repeat every time the inbox opens. Choice effects apply before choice event rules, so a rule can inspect a variable just set by the selected option. Rules evaluate in their authored array order; later rules can see earlier effects.

This rule fragment sends a confirmation only on the second day after a promise:

json
{
  "id": "dispatch-confirmation",
  "on": "dayStart",
  "when": {
    "all": [
      {"field": "day", "op": "==", "value": 2},
      {"field": "var.promised", "op": "==", "value": true}
    ]
  },
  "effects": [
    {"type": "mail", "from": "Dispatch", "subj": "Device found", "body": "The replacement is on the morning truck."}
  ]
}

Use once-only initialization carefully: if you set every variable to zero on every dayStart with once: false, you erase the previous day's story. For conditions on a terminal call outcome, compare the appropriate event fields rather than inferring resolution from a conversation line alone.

Recurring characters and CRM records

characters contains stable id, name, optional gender (m/f) and voiceType (my, mm, mo, fy, fm, fo). Set a scenario's characterId to connect it, and list character IDs in the campaign's characters array.

These become stable, searchable session CRM records. The same returning character can carry account/record edits through owned campaign state instead of being generated as an unrelated caller each time. Relationships are separate authored affinity values; changing relationship does not rewrite the displayed name or automatically grant a plan upgrade.

Use consistent caller actor choices for recorded returning characters. A character name does not replace the scenario's voice key in recording filenames. The scene below uses voice: "MIRA", so recordings use C-MIRA-... regardless of the character's display name.

Mail choices

Mail in career days, campaign days, modifiers or rule effects can include one to eight choices. Each has unique id, text, optional when and optional effects. Choices use the same bounded conditions/effects. The MailRoom displays enabled choices and stores a single response for that message.

This is a campaign-day mail fragment:

json
{
  "id": "dispatch-request",
  "from": "Dispatch",
  "subj": "Can you take this case?",
  "body": "We need one person to coordinate the missing delivery.",
  "choices": [
    {"id": "take-case", "text": "I will take ownership.", "effects": [{"type": "set", "key": "acceptedCase", "value": true}]},
    {"id": "decline-case", "text": "Please assign another agent.", "effects": [{"type": "set", "key": "acceptedCase", "value": false}]}
  ]
}

Use a stable mail id for authored messages that may be reordered. Responses survive resume, preventing repeated money/trophy payouts on reopening the inbox. A choice disappearing because its condition changed is different from a message already answered; test both, especially if a rule listens to mailChoice.

A complete small story content document

The following is a complete content.json for a two-day branching campaign. It requires a normal manifest with modes: ["campaign"], and no art/audio files or trophies. Give the pack its own stable ID. The downloadable Community Experiences starter ZIP includes a related working story, challenge and custom Night Shift example, along with its own reuse terms.

json
{
  "characters": [
    {"id": "mira", "name": "Mira Singh", "gender": "f", "voiceType": "fm"}
  ],
  "scenarios": [
    {
      "id": "MISSING_DELIVERY",
      "voice": "MIRA",
      "label": "Mira's missing delivery",
      "category": "Other",
      "weight": 1,
      "minDay": 1,
      "mood": "calm",
      "flags": [],
      "characterId": "mira",
      "openers": ["My replacement device still has not arrived."],
      "resolveLines": [],
      "solutions": [],
      "dialogue": {
        "start": "ask",
        "nodes": {
          "ask": {
            "text": "It was meant to arrive yesterday. Can you check?",
            "options": [
              {
                "id": "trace",
                "text": "I will trace it and follow up tomorrow.",
                "reply": "Thank you. I will keep my phone close.",
                "next": "confirm",
                "effects": [
                  {"type": "set", "key": "promised", "value": true},
                  {"type": "relationship", "characterId": "mira", "value": 10}
                ]
              },
              {"id": "wait", "text": "Please wait another day.", "reply": "I have already been waiting.", "resolve": "ok"}
            ]
          },
          "confirm": {
            "type": "finish",
            "quality": "best"
          }
        }
      }
    }
  ],
  "campaigns": [
    {
      "id": "missing-device",
      "name": "The missing device",
      "description": "Two shifts about keeping a promise.",
      "characters": ["mira"],
      "start": {"bank": 20, "stress": 5, "upgrades": {"callerid": 1}},
      "stockPolicy": {"events": false, "progression": false},
      "days": [
        {"label": "The promise", "intro": "A customer needs someone to listen.", "calls": 1, "minGapSec": 4, "callTimeTarget": 120, "scenarioIds": ["MISSING_DELIVERY"]},
        {"label": "The follow-through", "intro": "Finish the delivery case.", "calls": 1, "minGapSec": 4, "callTimeTarget": 120, "scenarioIds": ["MISSING_DELIVERY"]}
      ],
      "rules": [
        {
          "id": "confirmation",
          "on": "dayStart",
          "when": {"all": [{"field": "day", "op": "==", "value": 2}, {"field": "var.promised", "op": "==", "value": true}]},
          "effects": [{"type": "mail", "from": "Dispatch", "subj": "Delivery found", "body": "The replacement is on the morning truck. Tell Mira the good news."}]
        }
      ],
      "endings": [
        {"id": "promise-kept", "title": "A promise kept", "body": "Mira knows who to call next time.", "when": {"all": [{"field": "day", "op": ">=", "value": 2}, {"field": "var.promised", "op": "==", "value": true}]}},
        {"id": "case-closed", "title": "Case closed", "body": "The shift is over. A warmer response might have helped.", "when": {"field": "day", "op": ">=", "value": 2}}
      ]
    }
  ]
}

This deliberately short example demonstrates persistent decisions and mail. For a richer story, author a distinct return-call scenario and route it through a later chapter or follow-up. Repeating the same call here is a compact teaching choice, not a requirement that campaign chapters repeat their content.

Campaign configuration and endings

A campaign needs id, name and one to 100 days; optional description, start, characters, rules, endings, failureWhen, completionWhen, stockPolicy. Each day is a real shift configuration with calls, ring gap, call time target, pool/weights/specials and optional label/intro. Up to 32 emails can be attached to each campaign day.

Starting bank/stress/equipment and common shift bounds are detailed in Challenges and Night Shift. Campaign days advance through their authored sequence; stock career-day overrides are a different capability.

stockPolicy defaults to {events:false, progression:false}. events:true opts into stock office/narrative events. progression:true opts into normal career end-of-day progression between chapters. Keeping both false prevents unrelated stock arcs, random incidents, supervisor advancement and rival/prestige progression from entering an authored standalone story. Custom modes still suppress stock Steam awards even if a policy is enabled.

Up to 16 endings contain id, title, body, optional when. The first matching ending wins, so put specific endings before broad fallback endings. An explicit ending effect selecting a declared ending takes priority. Reaching the final authored day also completes the campaign. Optional failure/completion conditions are checked after call completion; early completion stops further scheduling and follows normal ticket/clock-out flow.

Save ownership and testing persistence

Each pack owns a separate modState entry containing variables, relationships, choices, answered mail, fired rules, pending follow-ups, recurring character records and isolated random state. Disabled content retains dormant state. Restored persistence is bounded: 200 variables, 100 relationships/character records, 500 tracked choices/mail responses, 1,024 fired rules and 64 pending follow-ups. Do not use a campaign to generate unlimited keys or endlessly accumulating queues.

Story scheduling/rule randomness is isolated from the stock content random generator. Repeating the same seed with the same data and choices is useful for reproducible tests. It does not guarantee the same state after a player chooses a different branch or the pack changes.

Start a campaign through Community Experiences in an empty save slot. Resume through that catalog or Load Game. Required frozen content must match the saved version and full hash; see Profiles and snapshots. A campaign choice saves owned state and the pending dialogue branch. Mid-call resume restores the authored branch, recurring customer, transcript and completion counters. This does not redefine ordinary stock-call save behavior.

Before release, test a choice, quit, resume and confirm that its effect did not run twice; answer a mail choice and confirm it stays answered; follow each ending; verify the last chapter clocks out; test missing required content; and repeat a branch using the previous release's save. Studio scratch tests are excellent for graph/debug checks, but scratch sessions deliberately do not create durable saves. Use a disposable normal campaign slot for persistence verification.