A generic host-defined LLM node uses the simplified v0.1 node contract:
config_schema/0declares configurable values such as model, reasoning effort, temperature, output field, and prompt template.call/3receives 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
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}
endMyApp.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/3string-keyed config. - App-owned context (like the LLM client) is passed as the
:contextrun option, and arrives at the node undercontext.application. The other context keys (run_id,node_id,step,attempt,source_versions,idempotency_key) are supplied by the runtime. - Constraints like
min/maxonDocket.Schema.float/1are 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.
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
endGiven this prompt:
"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
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:
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:
{: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.