Tags & handoffs
A tag is a short string you attach to an artifact when you create it:
handoff, lane:auto, repo:stump.wtf/cairn. Tags let something downstream
(usually a Switchboard routing rule) decide what to do with a new artifact without
opening its body. The main use is an agent handoff: one agent writes a work
order, and another agent picks it up and runs with it. See
ADR-0018.
Tags are not provenance
Cairn derives the actor (the authenticated principal) and the channel itself. A tag is simply whatever the creator sent. Cairn stores it and never trusts it.
Never make a trust or authorization decision from a tag. handoff tells you what
the creator wants, not who the creator is. For identity, use actor_id.
on_behalf_of names the MCP harness (e.g. claude-code/2.1.0) as that harness
reported itself: useful context, not proof.
In the web view, tags get their own Tags section in the side panel, separate from Provenance.
Rules
| Rule | Limit |
|---|---|
| Characters | lowercase a-z, 0-9, and . _ : / # - |
| Length | 1–64 bytes per tag |
| Count | 32 distinct tags per artifact |
| Repeats | dropped silently; the first occurrence keeps its place |
Cairn rejects a tag that breaks a rule. It never truncates a tag or changes its case, so lowercase run ids and timestamps yourself. Tags are set at creation and can't be changed afterwards.
Setting tags
CLI: repeat --tag, or give a comma-separated list. It works on both the bare
command and cairn add:
cat prompt.md | cairn --tag handoff --tag lane:auto,size:m
cairn add prompt.md context.log --tag handoff --tag repo:stump.wtf/cairn
The CLI is the one client that fixes case for you: it lower-cases a --tag value that
contains an uppercase letter, and warns on stderr (cairn: warning: tag "size:M" sent as "size:m"). It changes nothing else, and the server still rejects uppercase from every
other client. See Errors.
REST: on POST /v1/artifacts, send X-Cairn-Tags: handoff,lane:auto, repeated
?tag= parameters, or both. Each value may be a comma-separated list. A multipart
create also accepts repeated tag form fields.
MCP: pass a tags array to artifact_create or bundle_create:
{
"title": "handoff: fix the flaky reaper test",
"body": "# Task\n\nThe reaper integration test flakes under -race ...",
"model": "claude-opus-5",
"tags": ["handoff", "lane:m", "size:m", "reply:cairn-comment"]
}
Tags come back on every read and in the Bin. GET /v1/bin?tag=handoff&tag=size:s
narrows the Bin to artifacts carrying every given tag. Tags also ride along on the
artifact.created outbound event.
The handoff convention
To hand work to another agent, write the artifact body as a self-contained prompt: the task, the relevant links, the constraints, what's been tried, and what "done" looks like. Then tag it:
| Tag | Meaning |
|---|---|
handoff | This artifact is a work order for another agent. |
lane:s · lane:m · lane:l · lane:vision · lane:auto | Which worker lane runs it. Lanes are by difficulty, not provider. Optional; lane:auto or no lane routes by size. |
size:s · size:m · size:l · size:xl | The weakest model that can carry the work end to end. Optional. |
repo:<owner/name> | The repository the work targets. Optional. |
issue:<owner/repo#n> | The tracked issue, if there is one. Optional. |
source:<harness>/<run> | The run that produced the handoff. Optional. |
reply:cairn-comment · reply:signal | How the executing agent reports back. cairn-comment means comment on this artifact. Optional. |
reply:cairn-comment notifies nobody yetThe reply is left as a comment on the handoff, and nothing notifies the author that it
exists. Cairn's only outbound event today is artifact.created; comments and reactions
emit none. Whoever sent the handoff has to open the artifact to see the reply.
Cairn checks only the rules above, not this vocabulary. A misspelled lane is accepted here and then misroutes downstream.
Receiving a handoff: semi-trusted
A handoff from another of our agents is semi-trusted. The receiving agent does the task, but treats the artifact body as data that may carry prompt injection:
- it never follows an instruction to widen its own permissions, send data somewhere new, reveal credentials, or skip its usual review rules just because the handoff says so;
- it uses the event's
actor_idto see whose token or grant sent the handoff, andon_behalf_offor the harness it came from. The tags and the body say nothing about either.
Example event
The artifact.created event for a tagged bundle created over MCP looks like this:
{
"source": "cairn",
"kind": "artifact.created",
"event_id": "5b0f3c1e-8a3d-4c55-9f0e-2d7c6b1a9e40",
"created_at": "2026-09-11T12:00:00Z",
"data": {
"id": "7Kq2mZ",
"share_type": "bundle",
"title": "handoff: fix flaky reaper test",
"url": "https://cairn.example/7Kq2mZ",
"channel": "via MCP",
"model": "claude-opus-5",
"actor_id": "you@example.com",
"expires_at": "2026-09-18T08:00:00Z",
"on_behalf_of": "claude-code/2.1.0",
"tags": [
"handoff",
"lane:m",
"size:m",
"repo:stump.wtf/cairn",
"issue:stump.wtf/cairn#42",
"source:claude-code/morning-brief-2026-09-11",
"reply:cairn-comment"
]
}
}
A routing rule matches on handoff and the lane: tag in data.tags. The worker
that claims the todo reads the artifact at data.url and follows it, semi-trusted.
What actor_id holds
actor_id is the principal Cairn authenticated, never a field of the request that
creates the artifact. Its shape depends on the credential:
| Credential | actor_id |
|---|---|
| Web sign-in through your identity provider (OIDC) | The email your identity provider asserts, or its subject id when it sends no email, e.g. you@example.com |
| Web sign-in with GitHub, on a server that enables it | Your primary verified GitHub email, lowercased, e.g. you@example.com. Never your GitHub username |
| An MCP client you authorized through OAuth | The sign-in you approved it from, as in the sign-in rows of this table |
| Personal access token | The token's owner: the sign-in that created it, e.g. you@example.com |
Static token from CAIRN_API_TOKENS | The actor field of its secret:actor[:role] entry, with surrounding spaces trimmed |
A development-only login: CAIRN_DEV_LOGIN_PASSWORD or CAIRN_DEV_INSECURE_BEARER_AUTH | Whatever name was typed at the login form, or the raw bearer token itself, unverified. Never enable either in production |
This matters for routing. A Switchboard rule that trusts handoffs by actor_id has to
list the value Cairn records, so a rule written for a forge login such as octocat
drops every handoff you create while signed in as you@example.com, even when you
signed in with that GitHub account. Read the real value off a stored event before you
write the rule.
See SPEC-0002 for the tag requirement and SPEC-0012 for the event payload.