Cookbook
Storylines · for AI agents

Compile a flow into a Storyline

Turn a multi-step process into a stateful directed graph — create, validate, publish.

MCP tools:create_storylinevalidate_storylinepublish_storylinelist_storylinesupdate_storyline

A Storyline is a directed graph hung on an agent: it turns "answer one question" into "carry a whole task end to end" (intake, tutoring, coaching…). Nodes are steps (each with its own task / skills / kb / tools + writable profile dimensions); exits carry conditions (ai / user_choice / rule / callback / goto_storyline). The runtime never edits the agent's soul or safety boundary — it is constant from the first node to the last, which is exactly what a regulated or child-facing product needs.

Create a draft

create_storyline(
  agent_name="Support", key="loan-intake", name="Loan intake",
  profile_schema={"docs_ready": {"type": "bool"}},          # dims that accumulate across nodes
  graph={"nodes": [
    {"node_key": "n1", "title": "Understand need", "flags": {"is_entry": True},
     "on_enter_opening": "Hi — which kind of loan are you applying for?",
     "exits": [{"kind": "ai", "label": "need clear", "to_node_key": "n2",
                "ai_criteria": "the user stated the loan type and rough amount"}]},
    {"node_key": "n2", "title": "Collect documents",
     "task": "Collect the checklist items; chase missing ones across turns.",
     "exits": [{"kind": "user_choice", "label": "documents ready", "to_node_key": "n3",
                "user_choice": {"button_text": "I've uploaded everything"},
                "writes": [{"ref": "dim", "key": "docs_ready", "op": "set", "value": True}]}]},
    {"node_key": "n3", "title": "Hand off", "task": "Summarise the case and hand off to a human.",
     "flags": {"is_terminal": True}, "exits": []}
  ]},
  allow_agent_enroll=True,
  enroll_trigger="the visitor says they want to apply for a loan",
)

★ Validate, then publish ★

validate_storyline(storyline_id="…")   # → {"ok": true} is required to publish
publish_storyline(storyline_id="…")    # freezes an immutable version and goes live

Enrollment — who enters, and when (all require publishing)

  • is_default=True — one per agent; auto-enrolls on the first conversation.
  • allow_agent_enroll=True + enroll_trigger — the agent enrolls the user when it judges the trigger.
  • entry="user" (or "both") — the flow is listed in the chat widget's guided-flows menu and the New-chat dropdown, so the user starts it deliberately (a button, not a model judgement). Give it a display_name and a one-line description — that's what the menu shows. Combine with concurrency="session" for "one case per conversation" flows: each pick opens a fresh case.
  • Manual assignment in the console.

What the user sees — visibility, banner, and the read-only map

user_visibility controls the end-user presentation of a running flow (default invisible):

  • invisible — no UI at all; the flow drives silently (previous behaviour).
  • named — a slim banner above the chat shows the flow's name. Nothing else.
  • trail — banner + a View button opening a read-only map: steps already taken carry their titles; untaken branches and future steps are anonymous grey blocks (redacted server-side — the user sees how long the flow is, not what's in it).
  • full — banner with step x / y + the full map: every step named, colour-coded done / current / upcoming.

Extras: allow_exit=True adds an Exit button to the banner (an exited flow does not auto-re-enroll in that conversation; a user-picked flow can be re-entered from the menu, progress kept). show_profile=True lists the profile dimensions' current values under the map (numbers as progress bars when the schema declares min/max). Per node, rewind: "reset" | "keep" lets the user click a taken step in the full map and go back to it — reset restores the profile snapshot taken on arrival, keep keeps collected data (study/review flows). Real-world side effects (filed records, submitted documents) are never rolled back, so mark only side-effect-free steps. On IM channels (Telegram/WhatsApp) the /storyline command reports the current flow and, for trail/full, returns a short-lived signed link to the same read-only map.

The legacy learner_visibility parameter name and its hidden / completed_only values are still accepted (they map to invisible / trail) but are deprecated — use user_visibility.

Concurrency — does progress follow the person, or the case?

concurrency decides how many runs one user can have on this line:

  • "user" (default) — one run per person; every conversation continues the same progress. Right for curricula, onboarding, KYC: "how far has this person got".
  • "session"one run per conversation; a new chat starts a fresh, independent case. Right for licence applications, support tickets, per-product flows: "how far has this case got". The same user can run three product applications in three chats, each with its own progress and blackboard.

Put state where it belongs: case state on the blackboard (it lives and dies with the run — the product name, this application's document list), facts about the person in profile dimensions (they follow the user across runs and storylines — company details, verified identity). One case per conversation is deliberate: to open another case, open another chat. Changing concurrency affects only future enrolments; runs already in flight keep their key.

Checklists, gated choices, files, and deterministic actions

Four node-level capabilities turn "the AI decides when we're done" into accumulated, deterministic state:

  • checklist[{key, label}, …] on a node. Each turn the platform checks items off from what the visitor actually provides (uploads, or descriptions with concrete details — promises don't count), and an exit rule {"op": ">=", "left": {"ref": "checklist"}, "right": {"value": 1}} advances the moment everything is checked. The read-only map shows the user a live ✓/○ list. This is the right shape for application/approval flows: step 1 announces what's needed, step 2's checklist collects it item by item.
  • choices_offer: "on_ready" — game-style pacing for choice buttons: they stay hidden (and text-matching them is disabled — no skipping ahead by typing the button word) until the step is done — checklist complete, or a choices_ready_criteria you provide is judged true. Use user_choice for genuine decisions between alternatives; a lone "continue" button is a smell.
  • {"ref": "files"} in exit rules — the number of files/images uploaded while at this node (resets on transition). files >= 2 is a fully deterministic "documents received" gate.
  • questions — per-node icebreaker chips (max 6): the questions a user would likely ask at this step, shown as tappable suggestions while they're on it. Pair with on_enter_opening so every step both introduces itself and offers a way in — a user should never wonder "what am I supposed to do here".
  • on_enter_actions / on_complete_actions[{tool, args}] the platform executes deterministically when the step is entered / completes the flow (e.g. file the lead with mcp__agent4__submit_lead at the terminal step). String args interpolate dimension and blackboard keys with curly-brace templates. Failures are logged and never break the chat.

Context stays flat no matter how long the storyline is: each turn injects only the current node (task, resources, choices), the profile dimensions, and a size-capped blackboard — never the whole graph or past steps. Long journeys (a school year, a season-long game arc) should be chained storylines (on_complete: goto_next): each line's blackboard starts fresh while profile dimensions carry over, which is exactly "summarise the past, keep the present verbatim".

The exit judge now sees a recent-conversation window and the collected state, not just the last message — but keep ai_criteria about things the visitor said or did; the checklist mechanism above is still stronger than any prose criteria.

Design pitfalls — each of these has produced a stuck flow in production

You (the agent building this) are the most likely author of these bugs. Check every one before publishing:

  1. A rule exit needs a writer. readiness >= 100 is a dead end unless something actually writes readiness: an exit's writes (deterministic — preferred), a node's profile_writes with source: "ai" (scored from conversation), another storyline, or manual console edits. validate_storyline now flags this as warn.rule_unwritten_dim — treat that warning as a bug unless you know an outside writer exists. This exact mistake shipped once: a three-step intake whose second step gated on a dimension nothing wrote; users could never pass it.
  2. AI-scored dimensions almost never hit exact bounds. If the dimension is scored by the model, >= 100 (or == max) practically never fires — models score conservatively. Gate on a realistic threshold (>= 70), or make the step's completion a user_choice and use the exit's writes to set the value deterministically.
  3. user_choice matches the button text exactly — and the label must say what it does. The visitor's message must equal button_text. On visible lines the widget renders these as real buttons, so write complete, precise verb phrases — "Start document collection", not "Start" (start what? — real feedback from testing). Keep them distinct and in the audience's language. On invisible lines there are no buttons — restate the options in the node task. Prefer user_choice for genuine decisions (which branch, submit vs. keep editing); "am I done with this step" is the AI/rule exits' job, not a button.
  4. Publishing does not touch runs already in flight. Enrollments stay pinned to the revision they started on — a bug-fix release reaches only new runs. To move existing users, call POST /storylines/{id}/enrollments/migrate (reset back to the entry or move to a node — both re-pin the run to the current published revision).
  5. Walk the line as a user before handing it out. Open the share's /s/ page, enter the flow, click through every button and branch to the end. The editor's test-run simulates the graph; only the real page exercises openings, buttons, visibility and completion together.

Exits are priority-ordered (list order): deterministic rule / user_choice / callback are judged first, ai last. Build if/else, retry loops and AND-joins out of deterministic exits — don't gamble on the LLM. Edit a draft with update_storyline (PUT semantics; keep every node_key stable, since exits and the funnel reference it).

Report back. Return the published Storyline's console link — https://console.agent4.io/#/storylines/<id> (the id from create_storyline) — with a one-line summary of the flow and how a user enters it.