Skip to main content

Outbound webhooks to Switchboard

When an artifact is created, Cairn can send an artifact.created event to a list of URLs. The main consumer is Switchboard, which turns each event into a todo on an agent's queue. That's what makes agent handoffs run on their own.

Set per instance, not per person

Outbound webhooks are configured by whoever runs the Cairn instance, and they fire for every artifact created on it. The hosted service doesn't yet let you add your own target from Settings. If you want Cairn events in your own Switchboard, talk to the operator.

When it fires

Cairn sends an event after a new single-body artifact (markdown, code, image, file) or a new bundle is saved, whether it came from the REST API, the CLI, or an agent over MCP. Creating a trace or a webhook endpoint doesn't send one.

The event goes out in the background. It never slows down or fails the create request, even if every target is down.

What it sends

Each target gets a POST with a JSON body. It carries metadata about the artifact, never its content:

{
"source": "cairn",
"kind": "artifact.created",
"event_id": "6f1c1f0e-7f5b-4b8e-9a51-3f7f2c1d9b20",
"created_at": "2026-09-11T17:04:05Z",
"data": {
"id": "<id>",
"share_type": "markdown",
"title": "handoff: audit the backup job",
"url": "https://cairn.stump.wtf/<id>",
"channel": "via MCP",
"model": "claude-opus-5",
"actor_id": "you@example.com",
"expires_at": "2026-09-18T17:04:05Z",
"on_behalf_of": "claude-code/2.1.0",
"tags": ["handoff", "lane:m", "reply:cairn-comment"]
}
}
FieldMeaning
event_idUnique per event; use it to drop duplicates
created_atWhen Cairn emitted the event
data.idThe artifact's id; pass it to artifact_read
data.share_typemarkdown, code, image, file, gz, or bundle
data.titleThe title, if it has one
data.urlThe artifact's web link, which opens it for anyone who has it
data.channelHow it was created: via MCP, via API, or via web
data.modelThe model the creator reported, if any
data.actor_idThe person whose credential created it; the only field here that identifies anyone
data.expires_atWhen the artifact expires
data.on_behalf_ofFor artifacts created over MCP, the client's self-reported name and version
data.tagsThe creator's tags, if any; routing hints, never authorization

Empty optional fields are left out of the body.

Because the event includes the title and a working link, whatever receives it can open the artifact. That's one more reason to keep secrets out of titles and bodies.

Every delivery also carries these headers:

HeaderValue
Content-Typeapplication/json
User-Agentcairn-outboundhook/1
X-Cairn-Eventartifact.created
X-Cairn-Event-IdThe same value as event_id in the body
X-Cairn-Signaturesha256= and the lowercase hex HMAC-SHA256 of the raw body, keyed with the instance's secret. It's left out when the instance has no secret.

Verify the signature

Compute the HMAC over the raw request body, before any JSON parsing, and compare it with the header in constant time:

import hashlib
import hmac

def verify(raw_body: bytes, header: str, secret: bytes) -> bool:
expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
func verify(rawBody []byte, header string, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}

A Switchboard webhook created with source_type: "cairn" does this for you. It also rejects events whose created_at is more than five minutes old, and events whose X-Cairn-Event-Id header doesn't match the body. If verification fails, see Webhook signature mismatches.

Delivery is best-effort

The event is a doorbell, not a ledger. The artifact at its link is the record.

  • Cairn tries each target up to three times: once straight away, then after about a second, then after about four more. Each attempt times out after five seconds.
  • Any 2xx response counts as delivered. A 4xx other than 429 isn't retried.
  • Redirects aren't followed, and targets must use https (plain http is allowed only for a loopback address).
  • Events wait in a bounded in-memory queue. If Cairn restarts, or the queue fills up, pending events are dropped, and nothing can redeliver them.

So a receiver should answer 2xx quickly, deduplicate on event_id, and re-read the artifact before acting on it, since the artifact may have been rotated or have expired since the event was sent.

What a Switchboard routing rule sees

Switchboard runs each delivery through the webhook's routing rules, which are jq filters over an envelope it builds. The fields a Cairn rule usually needs:

PathValue
.source"cairn"
.kind"artifact.created"
.verifiedtrue when the signature checked out
.artifact.id, .artifact.url, .artifact.titleThe artifact's id, link, and title
.artifact.share_type, .artifact.channel, .artifact.modelIts type, how it was created, and the model
.artifact.actor_id, .artifact.expires_atWho created it, and when it expires
.artifact.tagsThe creator's tags, as an array
.artifact.event_id, .artifact.created_atThe event's id and timestamp
.payloadThe whole body Cairn sent, including data.on_behalf_of

Switchboard's envelope also has .artifact.metadata, which Cairn doesn't send, so it's null. For the complete envelope and the rule tools, see Switchboard's routing rules guide, and for a ready-made version of the example below, its Cairn handoff recipe.

Worked example: route handoffs to an agent pool

The goal: an artifact you created, tagged handoff and lane:m, becomes a todo on the medium pool's handoff queue. Everything else Cairn announces is recorded and dropped. You'll need Switchboard endpoints for your agents, and the Cairn operator's help for step 2.

  1. Create the webhook in Switchboard. From the endpoint that should own it, call create_webhook with source_type: "cairn" and target_queue: "inbox". Switchboard reveals a signing_secret and an ingest_url. Keep both somewhere safe.

  2. Point Cairn at it. The Cairn operator adds the ingest_url to the instance's outbound webhook targets (CAIRN_OUTBOUND_WEBHOOK_URLS) and sets its signing secret (CAIRN_OUTBOUND_WEBHOOK_SECRET) to the signing_secret. Cairn signs every target with one secret, so an instance feeds one signed Switchboard webhook, and that webhook routes to as many pools as you like.

  3. Route to the pool. Call add_webhook_route from that webhook to the medium pool's endpoint.

  4. Write the rule and test it. Pull a real delivery's event_id from list_webhook_events, dry-run your rules with test_webhook_rules, then save them with set_webhook_rules. A Cairn delivery with a bad signature is rejected before rules run, so the rule checks the creator first, and only then looks at the tags:

    {
    "webhook_id": "<cairn webhook id>",
    "rules": [
    {
    "id": "handoff-lane-m",
    "name": "my handoffs for the medium pool",
    "expr": ".artifact.actor_id == \"you@example.com\" and ((.artifact.tags | arrays // []) | any(. == \"handoff\") and any(. == \"lane:m\"))",
    "action": {"queue": "handoff", "endpoints": ["<medium pool endpoint id>"]}
    }
    ],
    "default_action": {"drop": true}
    }

    Don't guess the actor_id. Read it off a stored event with test_webhook_rules: it's whatever your credential authenticates as, which for a personal access token is its owner's sign-in, usually an email.

  5. Hand something off. An agent, or you, creates the handoff prompt:

    artifact_create(
    share_type: "markdown",
    title: "handoff: audit the backup job",
    body: "…the self-contained prompt…",
    tags: ["handoff", "lane:m", "reply:cairn-comment"]
    )
  6. Watch it land. Cairn emits the event, Switchboard verifies it, and the rule puts a todo on the medium pool's handoff queue, which rings that pool's doorbell. The worker claims the todo, takes data.id from the event, calls artifact_read, checks provenance.actor (see the trust model), does the work, comments on the artifact with a link to its result (that's what reply:cairn-comment asks for), and completes the todo.

Tags pick the pool; they never vouch for the sender. That's why the rule matches on actor_id and the worker checks provenance again before it acts.