Skip to content

Calling a model

Put an LLM in a workflow: store a key once, call the model from a step, and branch on what it said. The same steps work for Claude, anything OpenAI-compatible, and a model you host yourself.

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 an endpoint without auth needs no credential.

The managed environments that run connector steps can’t see your network, so a model on your own machine is reached by an agent you run next to it. Connectors shows the setup.

A credential is stored once per project, encrypted at rest, and referenced by name from a step, so the value never enters the graph document. Create it in the console under Settings > Connections > Credentials, or from the terminal:

Terminal window
# Claude
spool credential set anthropic --kind api-key --header x-api-key
# OpenAI (or any hosted OpenAI-compatible provider)
spool credential set openai --kind bearer

On a terminal each one prompts for the key without echo; from a script, pipe it in. No flag takes the key, so it stays out of your shell history:

Terminal window
printf '%s' "$ANTHROPIC_API_KEY" | spool credential set anthropic \
--kind api-key --header x-api-key

For a local model there is nothing to store: leave credential off the step and point base_url at the endpoint, such as http://localhost:11434/v1 for Ollama.

spool credential list shows names and kinds, never values.

One step. On the canvas, pick Anthropic’s Create message from the palette’s Integrations section, choose the credential, fill in model, and write the prompt. (The Presets entry with a similar name is a plain http step with the request written out.) As a document:

version: hyperspool-graph/v1
nodes:
- id: ask
type: anthropic.messages
config:
credential: anthropic
with: { model: claude-sonnet-4-6 }
input: { prompt: "In one sentence: what is a durable workflow?" }

Graph documents are YAML or JSON; the rest of this page uses JSON. Store and publish one from the terminal with spool workflow set <name> <file> --publish.

Run it, select the step, and the reply is in its output. Where depends on the provider, and it’s the first thing people get wrong:

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

After the graph’s first run, the canvas completes those paths: type nodes.ask.output. in any field and walk down the real response.

The same step against a local model, with no credential, sent to your own agent through a capability it advertises:

{
"id": "ask",
"type": "openai-compatible.chat",
"requires": ["local-llm"],
"config": {
"with": { "base_url": "http://localhost:11434/v1", "model": "llama3.2" },
"input": { "prompt": "In one sentence: what is a durable workflow?" }
}
}

Something arrives by webhook, a model decides what kind of thing it is, and the workflow routes accordingly.

{
"version": "hyperspool-graph/v1",
"nodes": [
{ "id": "inbox", "type": "trigger",
"config": { "transport": "webhook" } },
{ "id": "classify", "type": "anthropic.messages",
"retry": { "attempts": 3, "backoff": "10s" },
"config": {
"credential": "anthropic",
"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.trim().lowerAscii() == 'billing'" },
{ "name": "bug", "when": "nodes.classify.output.body.content[0].text.trim().lowerAscii() == 'bug'" }
],
"default": "other"
} },
{ "id": "page_billing", "type": "matrix.send_message",
"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",
"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", "when": "billing" },
{ "from": "route", "to": "file_bug", "when": "bug" }
]
}

Publish it (spool workflow set triage triage.json --publish) and the trigger step owns a webhook endpoint. Triggers covers how to find its URL and verify deliveries.

Each edge out of route names the case it belongs to, so only the step on the chosen branch runs, and an other reply runs neither. Pick one path with a switch has the rules.

A model told to “reply with the word and nothing else” mostly does. The cases trim whitespace, ignore case and compare the whole reply, so Billing matches and billing. with a full stop goes to default. If you need it exact, give the step a schema (next section). Either way the switch needs a default for the day the model doesn’t comply: without one, an unexpected answer fails the run.

Give a model step an output_schema and it answers in that shape: the request asks the provider for JSON matching the schema, the reply is parsed and checked, and the step’s output carries it as data. The classifier above, with a second field:

{ "id": "classify", "type": "anthropic.messages",
"retry": { "attempts": 3, "backoff": "10s" },
"config": {
"credential": "anthropic",
"with": { "model": "claude-sonnet-4-6" },
"input": {
"prompt": "Classify this support message.\n\n{{ input.payload.body.message }}",
"output_schema": {
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["billing", "bug", "other"] },
"urgent": { "type": "boolean" }
},
"required": ["category", "urgent"],
"additionalProperties": false
}
}
} }

The cases then compare a field instead of text: "when": "nodes.classify.output.data.category == 'billing'", and nodes.classify.output.data.urgent is a real boolean. The same input works on openai-compatible.chat.

The top of the schema must be an object, and providers ask for "additionalProperties": false on every object; OpenAI’s strict mode also wants every property in required. A reply that is not JSON or doesn’t match fails the step, and the error quotes what the model said. retry: sends the request again, so a model that slips once gets another try. When retries run out, the error lists why each attempt failed. For a provider without schema support, output_mode picks a fallback: tool on Anthropic, json_object on an OpenAI-compatible API. The reply is checked here either way. The node reference lists the details.

Bounds such as minimum, maximum or maxLength are checked on the reply whatever the provider supports. Anthropic’s structured outputs refuse them, so the request leaves them out there and only the check here enforces them.

A schema mode makes the model answer in the schema’s shape, which has a catch: an enum always gets one of its values, even when none is right. Ask for the capital of France with "enum": ["Oslo", "Madrid", "Tokyo"] and the step succeeds with one of the three. When the answer might not fit the list, add a value such as "unknown" or "other" and route it to a branch of its own.

retry: on the model call also covers a call that never completed and an error status from the provider, such as a 429 or a 503; errors and retries has the rules.

The model is called once per run. Its answer is recorded in the run’s log the moment it returns, and everything downstream reads the recorded answer, so a retried downstream step or a resumed run doesn’t call the model or pay for the tokens again. That holds because the call is a step of its own; a model call inside a script’s inner loop gets no such record.

Testing the branches without calling the model

Section titled “Testing the branches without calling the model”

Try it on the canvas dry-runs the draft: the model call and the Matrix posts are stubbed with what they returned in the graph’s newest real run, and the switch runs for real. So once the graph has run, you can change the cases and see where that reply goes without spending tokens or posting anything. Try it says what a dry run runs and stubs.

Webhooks get delivered twice, and without a guard that 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 wire claim in front of classify, and give both classify and route the guard if: "nodes.claim.output.claimed". A skipped step’s dependents still run, and reading a skipped step’s output fails the run, so route needs the guard as well as classify. The branch steps need none: a skipped switch picks no branch, so they’re skipped too. A second delivery of the same id starts a run that claims nothing and does nothing, 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 picks up where the last one left off. An entity-scoped state cell carries “how far it got” from one run to the next, because it outlives all of them.

{
"version": "hyperspool-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": "anthropic",
"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
spool workflow set daily-digest digest.json --publish
spool schedule create -w daily-digest --every 1d

The cursor is written after the post. 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), 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, with one wrinkle: a fan-out body can’t be a connector step. A body is an activity (http, shell, code) or workflow, so anthropic.messages as a body is refused at save time. Call the API directly instead, with credential: doing the same auth injection the connector would:

{ "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": "anthropic",
"headers": { "anthropic-version": "2023-06-01" },
"fail_on_error_status": true,
"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 keeps a list of forty repos from becoming forty simultaneous requests and a rate-limit error. fail_on_error_status makes an error status fail the item, which an http step otherwise records as data. The step’s output is the array of responses in item order, so a downstream step reads nodes.summarise.output[0].body.content[0].text.

When the limit is stated per minute, add rate: "rate": {"limit": 20, "period": "1m"} starts twenty, waits a minute on a durable timer once they have finished, then starts the next twenty. See pacing a fan-out.

To keep the connector, use a workflow body: put the connector step in a small graph that takes one item as input, and fan out over that graph. Each item becomes a child run, which is more machinery than an http body and pays off when the per-item work is more than one call.

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:

jobs:
review:
depends_on: [build]
workflow:
node:
kind: anthropic.messages
config:
credential: anthropic
with: { model: claude-sonnet-4-6 }
input: { prompt: "Review this changelog for anything release-blocking." }
outputs:
verdict: body

From a polyglot script, spool 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="$(spool call --node anthropic.messages \
--config '{"credential":"anthropic","with":{"model":"claude-sonnet-4-6"},
"input":{"prompt":"Is this changelog release-blocking? yes or no."}}')"

Choose as you would for any other work (see the authoring model): a canvas graph when the shape matters most, 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.
  • A model that decides whether a deploy needs a person: letting a model gate a deploy.
  • The connector reference, including Slack and Telegram for the notify half, is in Connectors.