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.
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 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.
Store the credential
Section titled “Store the credential”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:
# Claudespool credential set anthropic --kind api-key --header x-api-key
# OpenAI (or any hosted OpenAI-compatible provider)spool credential set openai --kind bearerOn 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:
printf '%s' "$ANTHROPIC_API_KEY" | spool credential set anthropic \ --kind api-key --header x-api-keyFor 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.
The smallest call
Section titled “The smallest call”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/v1nodes: - 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?" } }}Triage an inbound request
Section titled “Triage an inbound request”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.
Getting an answer in a fixed shape
Section titled “Getting an answer in a fixed shape”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.
Making it safe to redeliver
Section titled “Making it safe to redeliver”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.
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 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:
spool workflow set daily-digest digest.json --publishspool schedule create -w daily-digest --every 1dThe 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.
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, 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 pipeline or a script
Section titled “From a pipeline or a script”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: bodyFrom 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:
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.
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.
- 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.