Calling a model
Put an LLM in a workflow: store a key once, call the model from a node, and branch on what it said. Works the same whether the model is Claude, something OpenAI-compatible, or a llama running on your own laptop.
Pick a provider
Section titled “Pick a provider”| You want | Connector | Credential | Fields |
|---|---|---|---|
| Claude | anthropic |
api-key, header x-api-key |
model |
| OpenAI, Mistral, Groq, together.ai | openai-compatible |
bearer |
base_url, model |
| Ollama, vLLM, LM Studio on your own machine | openai-compatible |
none | base_url, model |
The second and third rows are the same connector. Everything that speaks
/chat/completions is one base_url apart, and a local endpoint needs no
credential at all.
Store the credential
Section titled “Store the credential”A credential is stored once per project, encrypted at rest, and referenced by name from a node. The value never enters the graph document.
# Claudecryo credential set claude --kind api-key --header x-api-key --value -
# OpenAI (or any hosted OpenAI-compatible provider)cryo credential set openai --kind bearer --token -- reads the secret from stdin, so it stays out of your shell history:
printf '%s' "$ANTHROPIC_API_KEY" | cryo credential set claude \ --kind api-key --header x-api-key --value -For Ollama there is nothing to store. Leave credential off the node and
point base_url at http://localhost:11434/v1.
Check what a project holds with cryo credential list — names and kinds,
never values.
The smallest call
Section titled “The smallest call”One node. On the canvas: add a node, pick Anthropic · messages from
the integration dropdown, choose the credential, fill in model, and
write the prompt. As a document:
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "ask", "type": "anthropic.messages", "config": { "credential": "claude", "with": { "model": "claude-sonnet-4-6" }, "input": { "prompt": "In one sentence: what is a durable workflow?" } } } ]}Run it, select the node, and the reply is in its output. Where depends on the provider, and it is the thing people get wrong first:
| Connector | The model’s text |
|---|---|
anthropic.messages |
nodes.ask.output.body.content[0].text |
openai-compatible.chat |
nodes.ask.output.body.choices[0].message.content |
Once the graph has run once, the canvas completes those paths for you —
type nodes.ask.output. in any field and walk down the real response.
The same node against a local model, with no credential:
{ "id": "ask", "type": "openai-compatible.chat", "config": { "with": { "base_url": "http://localhost:11434/v1", "model": "llama3.2" }, "input": { "prompt": "In one sentence: what is a durable workflow?" } }}A shape you’ll actually build: triage an inbound request
Section titled “A shape you’ll actually build: triage an inbound request”The common one. Something arrives by webhook, a model reads it and decides what kind of thing it is, and the workflow routes accordingly.
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "inbox", "type": "trigger", "config": { "transport": "webhook" } },
{ "id": "classify", "type": "anthropic.messages", "retry": { "attempts": 3, "backoff": "10s" }, "config": { "credential": "claude", "with": { "model": "claude-sonnet-4-6" }, "input": { "prompt": "Classify this support message as exactly one word — billing, bug, or other. Reply with the word and nothing else.\n\n{{ input.payload.body.message }}" } } },
{ "id": "route", "type": "switch", "config": { "cases": [ { "name": "billing", "when": "nodes.classify.output.body.content[0].text == 'billing'" }, { "name": "bug", "when": "nodes.classify.output.body.content[0].text == 'bug'" } ], "default": "other" } },
{ "id": "page_billing", "type": "matrix.send_message", "if": "nodes.route.output.branch == 'billing'", "config": { "credential": "matrix", "with": { "homeserver": "https://matrix.example.com", "room_id": "!billing:example.com" }, "input": { "text": { "$template": "Billing: {{ input.payload.body.message }}" } } } },
{ "id": "file_bug", "type": "matrix.send_message", "if": "nodes.route.output.branch == 'bug'", "config": { "credential": "matrix", "with": { "homeserver": "https://matrix.example.com", "room_id": "!bugs:example.com" }, "input": { "text": { "$template": "Bug: {{ input.payload.body.message }}" } } } } ], "edges": [ { "from": "inbox", "to": "classify" }, { "from": "classify", "to": "route" }, { "from": "route", "to": "page_billing" }, { "from": "route", "to": "file_bug" } ]}Publish it and the trigger node owns a webhook endpoint —
Triggers covers how to find its URL and verify deliveries.
Three things there are worth pointing at.
Asking for one word. A model told to “reply with the word and nothing
else” mostly does, and contains(...) tolerates the times it adds a full
stop. If you need it to be exact, ask for JSON and branch on a parsed
field — but be honest that you are now depending on the model’s
formatting, and give the switch a default that catches the day it
doesn’t comply. The default branch is not optional politeness; it is what
stops an unexpected answer from failing the run.
retry: on the model call. It covers a call that never completed - a
connection reset, a timeout, an agent that died mid-request. Three attempts
with a backoff cost nothing when the first works.
A provider that answers 429 or 503 is a different case: that call
completed, so its status lands in the node’s output for a downstream if:
to read and the retry: block never sees it. Name those codes to get the
retry instead:
{ "id": "ask", "type": "anthropic.messages", "retry": { "attempts": 4, "backoff": "2s" }, "config": { "credential": "claude", "with": { "model": "claude-sonnet-4-6" }, "input": { "prompt": "In one sentence: what is a durable workflow?" }, "retry_on": [429, 500, 529] }}Leave retry_on out and the status stays data, which is what you want when
you would rather branch on it than retry it.
The model is called once. Its answer is recorded in the run’s log the moment it returns, so everything downstream reads the recorded answer. A retried downstream step, a resumed run, a re-dispatched job after an agent died — none of them call the model again, and none of them pay for the tokens again. This is the durability you get for free by putting the call in a node rather than in a script’s inner loop.
Making it safe to redeliver
Section titled “Making it safe to redeliver”Webhooks get delivered twice. Without a guard, twice means two model calls and two messages in the room.
Claim the delivery before doing anything with it:
{ "id": "claim", "type": "state", "config": { "op": "add", "key": { "$template": "seen-{{ input.payload.body.id }}" }, "value": true, "entity": "inbox" } }Then gate the rest on nodes.claim.output.claimed, with claim upstream
of classify. A second delivery of the same id starts a run that claims
nothing and does nothing — which is the honest outcome, and leaves a
record that it happened. Durable state
covers the operation.
A digest on a schedule
Section titled “A digest on a schedule”The other common shape: nothing triggers this: it happens every morning, and each run has to pick up where the last one left off.
An entity-scoped state cell is what carries “how far did I get” from one run to the next, because it outlives any of them.
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "since", "type": "state", "config": { "op": "get", "key": "cursor", "entity": "digest" } },
{ "id": "fetch", "type": "http", "config": { "url": { "$template": "https://api.example.com/events?since={{ nodes.since.output.value }}" } } },
{ "id": "summarise", "type": "anthropic.messages", "retry": { "attempts": 3, "backoff": "10s" }, "config": { "credential": "claude", "with": { "model": "claude-sonnet-4-6" }, "input": { "prompt": "Summarise these events in five bullets for a morning standup.\n\n{{ nodes.fetch.output.body }}" } } },
{ "id": "post", "type": "matrix.send_message", "config": { "credential": "matrix", "with": { "homeserver": "https://matrix.example.com", "room_id": "!standup:example.com" }, "input": { "text": { "$template": "{{ nodes.summarise.output.body.content[0].text }}" } } } },
{ "id": "mark", "type": "state", "config": { "op": "set", "key": "cursor", "entity": "digest", "value": { "$expr": "nodes.fetch.output.body.now" } } } ], "edges": [ { "from": "since", "to": "fetch" }, { "from": "fetch", "to": "summarise" }, { "from": "summarise", "to": "post" }, { "from": "post", "to": "mark" } ]}Publish it as daily-digest and set it going:
cryo schedule create 1d --graph daily-digestThe cursor is written after the post, not before. If the post fails,
the cursor doesn’t move and tomorrow’s run covers the same window again —
a repeat is better than a silent gap. The first run reads an empty cell
and your fetch decides what that means (?since= with nothing after it,
or a default), which is the same first-tick question the
poll cursor has.
One prompt per item
Section titled “One prompt per item”Summarise every repo, every customer, every changed file — a list you
computed a moment ago. That’s fan_out, and there
is one wrinkle worth knowing before you write it.
A fan-out body can’t be a connector node. A body is an activity
(http, shell, code) or workflow, so anthropic.messages as a
body is refused at save time. The fan-out calls the API directly
instead, with credential: doing the same auth injection the connector
would have:
{ "id": "summarise", "type": "fan_out", "config": { "items": { "$expr": "nodes.repos.output.list" }, "max_parallel": 3, "body": { "type": "http", "config": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "credential": "claude", "headers": { "anthropic-version": "2023-06-01" }, "body": { "model": "claude-sonnet-4-6", "max_tokens": 512, "messages": [ { "role": "user", "content": { "$template": "Summarise this repo's week in two lines:\n\n{{ item.changelog }}" } } ] } } } } }max_parallel is doing real work here: it is what keeps a list of forty
repos from becoming forty simultaneous requests and a rate-limit error.
The node’s output is the array of responses in item order, so a
downstream node reads nodes.summarise.output[0].body.content[0].text.
When the limit you’re up against is stated per minute rather than per
moment, add rate next to it - "rate": {"limit": 20, "period": "1m"}
dispatches twenty, waits out the minute on a durable timer, then
dispatches the next twenty. See
pacing a fan-out.
If you want the connector and its managed credential after all, a
workflow body gets you there: put the connector node in a small graph
that takes one item as input, and fan out over that graph. Each item
becomes a child run - more machinery than an http body, worth it when
the per-item work is more than one call.
From a pipeline or a script
Section titled “From a pipeline or a script”A model call isn’t only a canvas thing.
From a YAML pipeline, a workflow: job runs a single node kind — the
connector included — so a CI pipeline can ask a model something mid-build
without leaving the pipeline:
jobs: review: depends_on: [build] workflow: node: kind: anthropic.messages config: credential: claude with: { model: claude-sonnet-4-6 } input: { prompt: "Review this changelog for anything release-blocking." } outputs: verdict: bodyFrom a polyglot script, cryo call node reaches the same kinds, and
the result is a durable checkpoint like any other step — so the model is
called once even if the script replays:
verdict="$(cryo call node anthropic.messages \ --config '{"credential":"claude","with":{"model":"claude-sonnet-4-6"}}' \ --input '{"prompt":"Is this changelog release-blocking? yes or no."}')"Which one you reach for is the usual authoring model question, not a model-specific one: a canvas graph when the shape is the point, a pipeline when it belongs to a build, a script when the logic around the call is more than a branch.
Where to go next
Section titled “Where to go next”- A model that needs several turns, with tool calls in between, is an agent loop rather than a single node.
- Summarise on a cadence instead of on a webhook: a schedule plus an entity-scoped state cell for “what did I already cover”.
- Fan a prompt across a list — one summary per repo, per customer, per
file — with
fan_out. - The connector reference, including Slack and Telegram for the notify half, is in Connectors.