Skip to content

Connectors

A connector lets a graph call a third-party API without writing the HTTP by hand. You name the connector and one of its actions, fill in a few fields, and point at a credential; the node turns into an http request at dispatch. The API’s URL, method, path, and header shape live in the connector, so a graph author picks an action instead of assembling a request.

A connector is a data-only manifest. It declares:

  • the credential kind it needs, spelled as the manifest schema wants it: bearer, api_key, basic, url_token,
  • author-filled fields - the per-install values like a base URL or a room id,
  • actions - each one a named operation that lowers to a single HTTP request, with its own inputs.

There is no code in a manifest. Importing one can’t run anything the author didn’t declare, and it can’t leak a credential: the credential name resolves to auth headers at dispatch, in the same instant the request is sent, and never resolves back to the manifest or its author.

A connector action is a node kind of its own: the node’s type is <connector>.<action>, and its config fills the manifest’s fields with with, the action’s inputs with input, and names a credential. This is what the canvas palette inserts, so a canvas-authored graph’s JSON reads the same way:

{ "id": "notify", "type": "matrix.send_message",
"config": {
"with": { "homeserver": "https://matrix.example",
"room_id": "!abc:example" },
"input": { "text": "deploy finished" },
"credential": "matrix"
} }

The node lowers to an http node before it runs - same dispatch, same claim-time credential injection, same run history. Everything true of an http node’s credential handling applies here: the definition, the queue, and the run log carry only the credential name; the value becomes a header the moment the request goes out.

A response status is data. A 503 comes back as output.status with the body beside it, the activity succeeds, and a downstream expression reads nodes.fetch.output.status to decide what happens next. That is still the default, so a graph that branches on a code keeps working.

What it costs you is retries. A node’s retry: policy only fires on a failed activity, so it never saw the 503. Two config fields nominate statuses that should fail the request instead:

  • retry_on: [429, 503] fails on the codes you list.
  • fail_on_error_status: true fails on any 4xx or 5xx.

Either alone is enough, and they combine. A connector node takes them beside with, input and credential, since the action lowers to the same http request:

{ "id": "ask", "type": "anthropic.messages",
"config": {
"credential": "claude",
"with": { "model": "claude-sonnet-4-6" },
"input": { "prompt": "In one sentence: what is a durable workflow?" },
"retry_on": [429, 500, 529]
},
"retry": { "attempts": 4, "backoff": "2s" } }

Against a provider that answers 429 twice and then 200, that node makes three requests and succeeds. Drop retry_on and it makes one, records status 429, and reports success. The error names the URL and the status and carries the first 200 characters of the body, because the reason a call was rejected is usually in there.

An http node spells both fields the same way:

{ "id": "fetch", "type": "http",
"config": { "url": "https://api.example.com/v1/things",
"retry_on": [429, 503] },
"retry": { "attempts": 3, "backoff": "10s" } }

A code you didn’t nominate stays data: retry_on: [429] in front of a 503 returns normally. And continue_on_error: true absorbs the failure the way it absorbs any other.

Both fields sit on the node, so two graphs calling the same action can disagree about which statuses matter, and the canvas offers them in the connector node inspector alongside the action’s own fields. A connector node that sets neither dispatches exactly the request it always did. Leaving them out is right when you would rather branch on the code than retry it.

matrix ships in the box. It posts a message to a Matrix room.

  • credential kind: bearer
  • fields: homeserver, room_id
  • action send_message: input text

The send carries the node’s idempotency_key as its transaction id, so a retried node re-sends the same transaction and the room shows one message rather than two. That applies to every item of a fan_out too - each gets its own.

Create the credential once, then reference it by name:

Terminal window
cryo credential set matrix --kind bearer --token - # access token on stdin

The connector’s declared credential kind (bearer for matrix) must match the kind of the stored credential. A mismatch is rejected rather than sent with the wrong auth shape.

One spelling difference to know about: cryo credential set --kind hyphenates the two-word kinds where a manifest writes them with underscores. The examples below show each one as the command wants it.

anthropic calls the Claude Messages API, so a graph can put a model in the loop. Calling a model walks the whole path - credential, first call, and a workflow that routes on the reply.

  • credential kind: api_key (set the header to x-api-key)
  • field: model (e.g. claude-sonnet-4-6)
  • action messages: input prompt
Terminal window
cryo credential set anthropic --kind api-key --header x-api-key --value -

A messages node’s output is the API response, so a downstream node reads the reply at nodes.<id>.output.body.content[0].text. One use: a trigger: run.failed graph that feeds the error to anthropic and posts the model’s take to matrix - a second failure handler alongside a plain notifier, since many handlers can match one event.

openai-compatible calls the /chat/completions API that OpenAI, Mistral, Ollama, vLLM, LM Studio, and LiteLLM all speak. One connector covers all of them - the only difference is the base_url:

  • credential kind: bearer, optional - omit it entirely for a local, no-auth endpoint (Ollama, vLLM).
  • fields: base_url (e.g. https://api.openai.com/v1, https://api.mistral.ai/v1, http://localhost:11434/v1) and model.
  • action chat: input prompt (required) and system (optional system prompt).
Terminal window
# Hosted (OpenAI, Mistral, …): a bearer token.
cryo credential set openai --kind bearer --token -
# Local (Ollama, vLLM): no credential at all - leave `credential` unset
# on the node and point base_url at the endpoint.

The reply is at nodes.<id>.output.body.choices[0].message.content. A node with base_url: http://localhost:11434/v1, model: llama3.2, and no credential runs entirely against a model on your own machine.

Two more chat targets ship in the box:

  • slack - action post_message, field channel, credential kind bearer (a bot token). Slack mrkdwn works in the text (*bold*, _italic_, `code`).
  • telegram - action send_message, field chat_id, credential kind url_token (the bot token goes in the request URL, injected at dispatch).
Terminal window
cryo credential set slack --kind bearer --token - # xoxb-… bot token
cryo credential set telegram --kind url-token --token - # Telegram bot token

Between them the built-ins cover every credential kind: matrix (bearer), anthropic (api_key), slack (bearer), telegram (url_token), and openai-compatible (optional bearer).

The forge connectors below are bearer too.

Reporting a run back to the commit that started it. github (and gitea, which covers Forgejo - same statuses API) each carry two actions, both credential kind bearer:

  • set_commit_status takes repo, sha and state as inputs, for a job that decides for itself what to report.
  • report_run_status takes a run.* lifecycle envelope whole and derives them, so the everyday case needs no expression.

Fields are context (the check’s name on the commit, default cryosleep) and api_base - optional for github, where it means GitHub Enterprise, and required for gitea, which has no default host.

The whole binding is one handler:

Terminal window
cryo credential set forge --kind bearer --token - # a token with repo:status
cryo handler set forge-status --on 'run.*' \
--when 'has(event.payload.trigger.repo) && has(event.payload.trigger.sha)' \
--node github.report_run_status \
--config '{"credential":"forge","with":{"context":"cryosleep/ci"},"input":{"$expr":"input"}}'

input: {"$expr": "input"} hands the action the lifecycle envelope whole; it reads payload.trigger.repo, payload.trigger.sha and payload.run_url out of it. The state mapping lives in the manifest: run.started posts pending, run.succeeded posts success, run.failed posts failure, run.cancelled posts error.

The --when guard matters, and it has to name both fields the action reads. A handler on run.* fires for every run in the project, and every run carries a trigger - a manual cryo submit’s is {"type": "manual"} - so guarding on trigger alone admits runs with no commit and the action then fails on the missing key. repo alone is not enough either: a delivery can shape a repo without a commit sha (a PR comment, an issue), and the action reads both. Guard on the fields you are about to read.

run.cancelled and any lifecycle type this manifest doesn’t know post error, not pending. A stuck-pending check never resolves, so branch protection would block the merge with nothing to click.

One context is one check on the commit, so two pipelines reporting on the same commit need two context values or the second overwrites the first.

A message input can take markdown. In the graph node inspector the Matrix text input is a markdown editor - a text box with a bold/italic/code/link toolbar and a live preview. Write markdown, and the node stores both the plain source and the rendered HTML, so the message posts formatted (bold, code, links) with a plain-text fallback for clients that don’t render HTML.

Interpolation works inside any connector input or with string: **Error:** {{ input.payload.error }} bolds the label and fills the value at send time, and {{ nodes.reason.output.body.content[0].text }} pulls an upstream node’s output straight into the message - a bare {{ … }} hole in a connector field renders against the graph scope, no $expr wrapper needed. (Code and shell scripts don’t get this; their {{ stays literal.) A connector marks an input as markdown in its manifest, so any connector with a message field gets the same editor.

Connector credentials are the typed secrets described in secrets. You create them per project with cryo credential set, reference them by name from a node, and the value never comes back out of the API - cryo credential list shows names and kinds only.

A connector node that names a credential which doesn’t exist fails the same way an http node does: rejected at submit when the graph is saved, or failed before the request is sent when the name is resolved dynamically. It never falls back to an unauthenticated call.

The built-in manifests are listed by the connectors endpoint:

GET /api/v1/orgs/{org}/projects/{project}/connectors

The canvas connector-node inspector reads a manifest to render its picker: choose an action, and it lays out that action’s fields and inputs for you to fill. Custom and community manifests are the same JSON shape and are planned to layer on top of the built-ins, so a node that references one works no differently.

  • Secrets - how credentials are stored, injected, and masked.
  • Triggers - the inbound side, for a graph that a connector node reacts inside of.
  • Events and handlers - the event bus a connector node often posts about.