Skip to content

Workflows, Graphs, and Nodes

A Tryll agent is not a prompt. It is a graph of small, single-purpose steps that you wire together. Each user turn re-walks the graph; each step decides where to go next. This page explains why Tryll is built this way and how the pieces fit.

The building blocks

A graph has two things:

  • A set of nodes, each with a name, a node typeGenerate, Retrieve, ToolCall, CannedResponse, or HumanMessageGuardrail — and a set of typed params that include the node's configuration and its exit wiring. Each exit is a string field on the params object (e.g. default_exit, triggered_exit); an empty value means route to END.
  • A start node — the first node to run on every user turn.

Graphs are authored once, sent to the server with CreateAgentRequest, frozen at agent creation, and then re-walked for every turn the agent lives.

Here is the simplest useful graph — a guardrail in front of a generator, with a scripted refusal on the rejected path:

flowchart LR
    guard["HumanMessageGuardrail<br>guard"]
    refuse["CannedResponse<br>refuse"]
    gen["Generate<br>answer"]
    guard -- "triggered" --> refuse
    guard -- "not_triggered" --> gen
    refuse -- "default" --> END
    gen -- "default" --> END

The same graph, in each client:

var graph = new TryllGraphBuilder()
    .AddHumanMessageGuardrail("guard", new TryllHumanMessageGuardrailParams
    {
        StringStorage    = "jailbreak_patterns",
        TriggeredExit    = "refuse",  // → refuse node
        NotTriggeredExit = "answer",  // → answer node
    })
    .AddCannedResponse("refuse", new TryllCannedResponseParams
    {
        StringStorage = "refusal_lines",
        // DefaultExit is "" (END) by default
    })
    .AddGenerate("answer", new TryllGenerateParams())
    .SetStartNode("guard")
    .SetDefaultModelName("My Local Model")
    .Build();
auto* GuardParams  = NewObject<UTryllHumanMessageGuardrailParams>();
GuardParams->StringStorage    = TEXT("jailbreak_patterns");
GuardParams->TriggeredExit    = TEXT("refuse");
GuardParams->NotTriggeredExit = TEXT("answer");

auto* RefuseParams = NewObject<UTryllCannedResponseParams>();
RefuseParams->StringStorage = TEXT("refusal_lines");
// RefuseParams->DefaultExit defaults to "" (END)

FTryllGraphDescription Graph = FTryllGraphBuilder()
    .AddNode(TEXT("guard"),  GuardParams)
    .AddNode(TEXT("refuse"), RefuseParams)
    .AddNode(TEXT("answer"), NewObject<UTryllGenerateParams>())
    .SetStartNode(TEXT("guard"))
    .SetDefaultModelName(TEXT("My Local Model"))
    .Build();
namespace TC = Tryll::Client;

TC::HumanMessageGuardrailParams guardParams;
guardParams.string_storage     = "jailbreak_patterns";
guardParams.triggered_exit     = "refuse";
guardParams.not_triggered_exit = "answer";

TC::CannedResponseParams refuseParams;
refuseParams.string_storage = "refusal_lines";
// refuseParams.default_exit defaults to "" (END)

TC::GraphDescription graph;
graph.AddNode("guard",  guardParams)
     .AddNode("refuse", refuseParams)
     .AddNode("answer", TC::GenerateParams{})
     .SetStartNode("guard")
     .SetDefaultModelName("My Local Model");
from tryll_client import GraphDescription
from tryll_client._generated.nodes import (
    HumanMessageGuardrailParams, CannedResponseParams, GenerateParams,
)

guard_params = HumanMessageGuardrailParams(
    string_storage     = "jailbreak_patterns",
    triggered_exit     = "refuse",
    not_triggered_exit = "answer",
)
refuse_params = CannedResponseParams(
    string_storage = "refusal_lines",
    # default_exit defaults to "" (END)
)

graph = (
    GraphDescription()
    .add_node("guard",  guard_params)
    .add_node("refuse", refuse_params)
    .add_node("answer", GenerateParams())
    .set_start_node("guard")
    .set_default_model_name("My Local Model")
)

Exit fields: how nodes "talk" to each other

Nodes do not call each other. They return a named exit, and the agent follows the matching edge. Every node type declares its possible exits as typed string fields on its params object — see the table in Workflow Nodes.

For example:

Node type Exit fields on params
Generate default_exit
Retrieve found_exit, not_found_exit
ToolCall tool_called_exit, no_tool_called_exit
CannedResponse default_exit
HumanMessageGuardrail triggered_exit, not_triggered_exit

An empty string means route to END; this is the field's default value. A non-empty string must name another node in the same graph — the server rejects graphs where an exit field names a node that doesn't exist (3008 InvalidExitTarget).

Because exits are fields on the params object, exits that terminate the turn (route to END) can simply be left at their default — you only need to set the fields that route somewhere other than END.

The per-turn loop

When a SendMessage arrives, the agent:

  1. Appends a new interaction to its dialog, starting with the user's message.
  2. Walks the graph from start_at, one node at a time. Each node:
  3. reads from the current interaction (and anything earlier nodes attached to it),
  4. does its work (retrieve from an index, run the model, match a pattern, pick a canned line, …),
  5. optionally attaches new components to the interaction, and
  6. returns its chosen exit.
  7. Follows the route for that exit to the next node.
  8. Stops when the route target is "END", when the step budget is exceeded, or when the turn is cancelled.

What each node attaches along the way is what makes the dialog itself useful state. A Retrieve node attaches retrieved knowledge to the current turn, and a downstream Generate node's projection picks that knowledge up and folds it into the prompt.

Why a graph instead of a prompt template

Three reasons:

1. Composition. "Retrieve, then generate" is different from "check for jailbreak first, then retrieve, then generate". Prompt templates push this branching into strings; a graph makes it executable and inspectable. Every node is typed and diagnostics-friendly.

2. Cost control. The graph lets you short-circuit expensive work. A guardrail that routes to a canned response never touches the model. A tool-call node with generate_on_no_tool=false (experimental) does zero extra generation on a miss.

3. Reuse. Graphs are data. You can build them at runtime from C++, Python, or Unreal, ship them in a UTryllWorkflowAsset, or construct them dynamically from user configuration. The server does not know or care where the graph came from.

Turns are serial; graphs are stateless between turns

An agent runs one turn at a time. If a second SendMessage arrives while the previous turn is still running, it is rejected with error 3001; the in-flight turn keeps running. Cancellation is explicit (DestroyAgent, socket close), not pre-emption.

The graph itself carries no per-turn state. All state lives on the agent's dialog (the accumulated interactions) and in the per-node KV caches. This is why the same graph description can be replayed cleanly every turn — the work of "what has been said" is separate from the work of "what to do next".

Edges and pitfalls

  • Routing loops. Nothing stops you from routing node A → node A; the agent has a configurable max_steps_per_turn budget (default 64) that breaks infinite loops by aborting the turn with an error.
  • Exits you "know" will never fire still need a value. Leave them at the default empty string (routes to END). The server validates that no exit field names a missing node, but it does not enforce that every exit field is reachable.
  • Model sharing is cross-graph, not cross-node. Two Generate nodes in one graph that use the same model share the underlying weights (good) but have independent KV caches (necessary — they see different prompts). Budget token-count accordingly; see Projection and Token Budgets.
  • The start node runs every turn. If your start node is Generate, it will generate every turn, even if a later node would have short-circuited. Put guardrails and retrieval before the model, not after, if you want them to actually gate work.