Skip to main content

Agent Versioning and Canary Rollout

Every write that changes an agent archives an immutable version, and a release serves two versions side by side so a prompt change reaches a slice of traffic first. Assignment is deterministic per end user.

You will version an agent, run a 50/50 canary release, verify sticky assignment, then promote and roll back.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

See Users for authentication.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create an agent (version 1)​

Version 1 is archived on create. See Projects and AI Providers.

PROJECT_ID=$(soat create-project --name "Rollout Demo" | jq -r '.id')

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Support Bot" \
--instructions "You are a formal support assistant. Answer in one short sentence." | jq -r '.id')

soat get-agent --agent-id "$AGENT_ID" | jq '{id, version, active_release}'
soat list-agent-versions --agent-id "$AGENT_ID" | jq '.data | map({version: .version, label: .label})'

Expected output:

{ "id": "agent_POESmEnF6bs58oSy", "version": 1, "active_release": null }

Step 3 — Edit the prompt and label the change​

version_label annotates the archived version only; it is not part of the config, so a label is never itself a change. See Agents — Versioning and Staged Rollout.

soat update-agent --agent-id "$AGENT_ID" \
--instructions "You are a warm, friendly support assistant. Answer in one short sentence, and open with a greeting." \
--version-label "friendly-tone" \
| jq '{version}'

soat list-agent-versions --agent-id "$AGENT_ID" | jq '.data | map({version: .version, label: .label})'
soat get-agent-version --agent-id "$AGENT_ID" --version 1 | jq '{version, config: {instructions: .config.instructions}}'

PUT, PATCH and a formation apply leave identical history.


Step 4 — A write that changes nothing creates no version​

The comparison runs on the serialized config, not on the fields named. Resending the current instructions leaves the counter unchanged.

soat update-agent --agent-id "$AGENT_ID" \
--instructions "You are a warm, friendly support assistant. Answer in one short sentence, and open with a greeting." \
| jq '{version}'

Expected output — still 2:

{ "version": 2 }

Restoring the config an agent already holds is therefore a no-op.


Step 5 — Start a canary release​

Serve version 1 as the baseline and version 2 to half of the traffic. See Agents — Staged Rollout.

soat set-agent-release --agent-id "$AGENT_ID" \
--stable-version 1 --canary-version 2 --canary-percent 50 \
| jq '{version, active_release}'

Expected output:

{
"version": 2,
"active_release": {
"stable_version": 1,
"canary_version": 2,
"canary_percent": 50
}
}

Step 6 — Run traffic and read which version served it​

Assignment hashes the actor behind the request's session, falling back to the session. Create two end users and run a turn each.

ADA_ID=$(soat create-actor --project-id "$PROJECT_ID" \
--name "Ada" --external-id "+15551230001" | jq -r '.id')
BLAKE_ID=$(soat create-actor --project-id "$PROJECT_ID" \
--name "Blake" --external-id "+15551230002" | jq -r '.id')

ADA_SESSION_ID=$(soat create-session --agent-id "$AGENT_ID" \
--actor-id "$ADA_ID" --name "Ada session" --auto-generate false | jq -r '.id')
BLAKE_SESSION_ID=$(soat create-session --agent-id "$AGENT_ID" \
--actor-id "$BLAKE_ID" --name "Blake session" --auto-generate false | jq -r '.id')

soat add-session-message --session-id "$ADA_SESSION_ID" \
--message "What are your support hours?" > /dev/null
ADA_GEN_ID=$(soat generate-session-response --wait true --session-id "$ADA_SESSION_ID" | jq -r '.generation_id')

soat add-session-message --session-id "$BLAKE_SESSION_ID" \
--message "What are your support hours?" > /dev/null
BLAKE_GEN_ID=$(soat generate-session-response --wait true --session-id "$BLAKE_SESSION_ID" | jq -r '.generation_id')

soat get-generation --generation-id "$ADA_GEN_ID" | jq '{actor: "Ada", agent_version}'
soat get-generation --generation-id "$BLAKE_GEN_ID" | jq '{actor: "Blake", agent_version}'

Assignment is sticky: a second turn for the same actor lands on the same version.

soat add-session-message --session-id "$ADA_SESSION_ID" \
--message "And on weekends?" > /dev/null
ADA_GEN_2_ID=$(soat generate-session-response --wait true --session-id "$ADA_SESSION_ID" | jq -r '.generation_id')

soat get-generation --generation-id "$ADA_GEN_2_ID" | jq '{actor: "Ada", turn: 2, agent_version}'

agent_version is a server-owned field on the generation. Assignment edge cases (anonymous requests, live-read fields): Agents — Staged Rollout.


Step 7 — Keep editing while the canary runs​

While a release is active the agent's live columns are a draft: edits archive new versions without disturbing either side of the split.

soat update-agent --agent-id "$AGENT_ID" \
--instructions "You are a warm support assistant. Answer in one short sentence and always offer a follow-up." \
--version-label "draft-followup" \
| jq '{version, active_release}'

Version 3 is in history but serves no traffic; the split still runs 1 against 2.


Step 8 — Promote the canary (or abort it)​

promote makes the canary's config live; abort restores the stable one.

soat promote-agent-release --agent-id "$AGENT_ID" \
| jq '{version, active_release, instructions}'

Both write the winning version's config before clearing the pointer; the version 3 draft stays in history unreleased.

Ending a rollout that is not running is a conflict:

# → expect-fail
soat abort-agent-release --agent-id "$AGENT_ID"

Step 9 — Roll back by restoring a version​

restore-agent-version copies an archived config onto the agent as a new version; history stays append-only and undoing a restore is another restore.

soat restore-agent-version --agent-id "$AGENT_ID" --version 1 \
| jq '{version, instructions}'

soat list-agent-versions --agent-id "$AGENT_ID" | jq '.data | map({version: .version, label: .label})'
  • The restored config fully replaces the current one: fields the archived version did not set are cleared, not merged.
  • It re-validates: a tool, provider or guardrail deleted since the snapshot fails the request.

What's next​

Agents — Versioning and Staged Rollout, Generations, Debug Session, Generation, and Trace History.