Current section
Files
Jump to
Current section
Files
examples/llm-node.md
# LLM Node Example
A generic host-defined LLM node uses the simplified v0.1 node contract:
- `config_schema/0` declares configurable values such as model, reasoning
effort, temperature, output field, and prompt template.
- `call/3` receives the current graph state, normalized node config, and runtime
context.
- The node returns a partial state update: only the fields it wants to change.
The graph node instance does not store per-node input bindings, output bindings,
reads, or writes. Prompt variables are interpreted as graph state keys. If a
prompt says `{{customer_message}}`, the node reads `state["customer_message"]`.
## Node Implementation
```elixir
defmodule MyApp.DocketNodes.LLM do
@behaviour Docket.Node
@impl true
def config_schema do
Docket.Schema.object(%{
model: Docket.Schema.string(required: true),
reasoning_effort:
Docket.Schema.enum(["none", "low", "medium", "high"],
default: "medium"
),
temperature:
Docket.Schema.float(
min: 0.0,
max: 2.0,
default: 0.4
),
system_prompt: Docket.Schema.string(default: ""),
prompt_template: Docket.Schema.string(required: true),
output_field: Docket.Schema.string(required: true),
usage_field: Docket.Schema.string(required: false)
})
end
@impl true
def call(state, config, context) do
rendered_prompt = MyApp.PromptTemplate.render(config["prompt_template"], state)
messages =
[
system_message(config["system_prompt"]),
%{role: "user", content: rendered_prompt}
]
|> Enum.reject(&is_nil/1)
request = %{
model: config["model"],
reasoning_effort: config["reasoning_effort"],
temperature: config["temperature"],
messages: messages,
idempotency_key: context.idempotency_key
}
with {:ok, client} <- fetch_llm_client(context),
{:ok, response} <- client.chat(request) do
update =
%{}
|> Map.put(config["output_field"], response.text)
|> maybe_put(config["usage_field"], response.usage)
{:ok, update}
end
end
defp maybe_put(update, nil, _value), do: update
defp maybe_put(update, "", _value), do: update
defp maybe_put(update, field, value), do: Map.put(update, field, value)
defp system_message(""), do: nil
defp system_message(prompt), do: %{role: "system", content: prompt}
defp fetch_llm_client(%{application: %{llm_client: client}}), do: {:ok, client}
defp fetch_llm_client(_context), do: {:error, :missing_llm_client}
end
```
`MyApp.DocketNodes.LLM` does know graph state keys, but only because the node
instance config names them. The reusable implementation stays generic because
the prompt and output fields are configuration.
A few contract details worth noting:
- Config schemas may be declared with atom keys, but durable graph
normalization gives `call/3` **string-keyed** config.
- App-owned context (like the LLM client) is passed as the `:context` run
option, and arrives at the node under `context.application`. The other
context keys (`run_id`, `node_id`, `step`, `attempt`, `source_versions`,
`idempotency_key`) are supplied by the runtime.
- Constraints like `min`/`max` on `Docket.Schema.float/1` are enforced when
configuration and state values are validated.
## Prompt Template Helper
Template variables map directly to graph state keys. Applications may replace
this helper with another template engine while preserving that mapping.
```elixir
defmodule MyApp.PromptTemplate do
@variable_pattern ~r/{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}/
def variables(template) when is_binary(template) do
template
|> then(&Regex.scan(@variable_pattern, &1, capture: :all_but_first))
|> List.flatten()
|> Enum.uniq()
end
def render(template, state) when is_binary(template) and is_map(state) do
Regex.replace(@variable_pattern, template, fn _match, name ->
state
|> Map.fetch!(name)
|> to_string()
end)
end
end
```
Given this prompt:
```elixir
"Reply to {{customer_message}} using this context: {{account_context}}"
```
the app can infer that the graph should contain state keys named
`"customer_message"` and `"account_context"`. That inference is useful for an
editor or no-code builder, but it does not need to become a separate binding
layer in the canonical graph document.
## Graph Shape
```elixir
graph =
Docket.Graph.new!(id: "support-reply", name: "Support Reply")
|> Docket.Graph.put_input!("customer_message",
schema: Docket.Schema.string(),
required: true
)
|> Docket.Graph.put_field!("account_context",
schema: Docket.Schema.string(),
reducer: Docket.Reducer.last_value()
)
|> Docket.Graph.put_field!("draft_response",
schema: Docket.Schema.string(),
reducer: Docket.Reducer.last_value()
)
|> Docket.Graph.put_field!("llm_usage",
schema: Docket.Schema.map(),
reducer: Docket.Reducer.last_value()
)
|> Docket.Graph.put_node!("draft_reply",
implementation: MyApp.DocketNodes.LLM,
config: %{
model: "gpt-4.1-mini",
reasoning_effort: "medium",
temperature: 0.3,
system_prompt: "You write concise customer support replies.",
prompt_template:
"Reply to {{customer_message}} using this context: {{account_context}}",
output_field: "draft_response",
usage_field: "llm_usage"
}
)
|> Docket.Graph.put_edge!("edge_start_draft_reply", from: "$start", to: "draft_reply")
|> Docket.Graph.put_edge!("edge_draft_reply_finish", from: "draft_reply", to: "$finish")
|> Docket.Graph.put_output!("draft_response", [])
```
## Runtime Call
At runtime, the dispatcher calls the node with the committed state snapshot:
```elixir
state = %{
"customer_message" => "I was charged twice for my subscription.",
"account_context" => "Customer is on the Pro plan. Refund policy allows one reversal."
}
config = %{
"model" => "gpt-4.1-mini",
"reasoning_effort" => "medium",
"temperature" => 0.3,
"system_prompt" => "You write concise customer support replies.",
"prompt_template" =>
"Reply to {{customer_message}} using this context: {{account_context}}",
"output_field" => "draft_response",
"usage_field" => "llm_usage"
}
context = %{
run_id: "run_123",
node_id: "draft_reply",
step: 1,
attempt: 1,
source_versions: %{"customer_message" => 1, "account_context" => 1},
idempotency_key: "run_123:1:draft_reply:1",
application: %{llm_client: client}
}
MyApp.DocketNodes.LLM.call(state, config, context)
```
The node returns a partial state update:
```elixir
{:ok,
%{
"draft_response" => "I can help with that. I found the duplicate charge...",
"llm_usage" => %{
"input_tokens" => 64,
"output_tokens" => 28
}
}}
```
The runtime validates update keys against graph fields, validates values against
field schemas, applies reducers, and emits compiler-generated edge activations
after successful node completion.
## Graph Model Boundary
This keeps the durable graph model smaller:
- graph fields define shared state
- graph inputs seed shared state
- graph outputs project shared state
- node config binds generic implementations to graph-specific state keys
- node returns are ordinary partial state updates
No-code tooling can still parse prompt variables, suggest missing fields, and
warn about deleted fields. Those are editor concerns over the graph state model,
not a second canonical input/output binding model.