Skip to content

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.

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.

A credential is stored once per project, encrypted at rest, and referenced by name from a node. The value never enters the graph document.

Terminal window
# Claude
cryo 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:

Terminal window
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.

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.

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.

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:

Terminal window
cryo schedule create 1d --graph daily-digest

The 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.

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.

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: body

From 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:

Terminal window
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.

  • 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.