The code is deployed. The flag is off. The question is not "can we ship?" — it's "should 25% of production accounts see this behavior?" An exposure gate puts a human decision between one rollout stage and the next, with the evidence and the rollback plan right next to the buttons.
The pattern: approve exposure, not deployment
A deployment gate answers "is the artifact good enough for an environment?". An exposure gate answers a different question: "is the evidence good enough to widen the audience?" Keep them separate:
| Stage | What changes | Gate |
|---|---|---|
| Deploy | Artifact lands on servers, flag off | CI checks, no human needed |
| Internal | Flag on for the team | No gate |
| Beta | 5% of low-risk accounts | Approve with evidence |
| Ramp | 25% of production | Approve with guardrails |
| Launch | 100% | Approve + rollback check |
Step 1: the rollout job creates the approval
#!/usr/bin/env bash
# promote-flag.sh — ask before widening exposure
set -euo pipefail
FLAG_KEY="${1:?flag key}"
NEXT_PERCENT="${2:?next rollout percent}"
CURRENT=$(curl -s -H "Authorization: Bearer $FLAGS_API_TOKEN" \
"$FLAGS_API_URL/flags/$FLAG_KEY" | jq -r .rollout.percent)
if [ "$NEXT_PERCENT" -le "$CURRENT" ]; then
echo "Rollout is not increasing ($CURRENT% -> $NEXT_PERCENT%)"
exit 0
fi
actionbox ask \
"Expand ${FLAG_KEY} from ${CURRENT}% to ${NEXT_PERCENT}%?" \
--option approve="Approve expansion" \
--option hold="Hold at ${CURRENT}%" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"${FLAG_KEY}\",\"from\":\"${CURRENT}%\",\"to\":\"${NEXT_PERCENT}%\",\"guardrails\":\"error_rate < 0.5% · p95 < 800ms\"}}]" \
--callback-url https://ops.acme.com/actionbox/flag-callbackThe callback does the actual flag update only when the decision resolves to approve — the script never touches the flag directly.
Step 2: attach the evidence to the approval
An approval without evidence is a rubber stamp. Include the numbers that justify the next stage:
actionbox ask \
"Expand checkout_v2 to 25% of accounts?" \
--option approve="Approve expansion" \
--option hold="Hold at 5%" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"checkout_v2\",\"from\":\"5%\",\"to\":\"25%\",\"error_rate_24h\":\"0.12%\",\"checkout_completion\":\"+1.4% vs baseline\",\"p95_latency\":\"742ms\",\"support_tickets\":\"0 flag-related\",\"rollback\":\"set flag to baseline for affected segment\"}}]" \
--callback-url https://ops.acme.com/actionbox/flag-callbackThe approver sees: what's changing, the guardrail signals, and the exact rollback action — in the dashboard, before tapping approve.
Step 3: fail-closed on timeout
Nobody answers in time? The rollout should not happen. Make the default explicit:
actionbox ask \
"Expand ${FLAG_KEY} to ${NEXT_PERCENT}%?" \
--option approve="Approve expansion" \
--option hold="Hold at ${CURRENT}%" \
--expires "$(date -u -d '+4 hours' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v+4H '+%Y-%m-%dT%H:%M:%SZ')" \
--on-expire-json '{"type":"resolve","response":{"type":"single_choice","value":"hold"},"reason":"no response within rollout SLA"}' \
--callback-url https://ops.acme.com/actionbox/flag-callbackThe audit trail records: the request, the decision (or the non-decision), and the reason — so the Friday 5pm "why did the flag stay at 5%?" question answers itself.
Full example: staged rollout with a cleanup reminder
#!/usr/bin/env bash
# staged-rollout.sh — 5% -> 25% -> 100%, each stage gated
set -euo pipefail
FLAG_KEY="billing_checkout_v2"
STAGES=(5 25 100)
ROLLBACK="set ${FLAG_KEY} to baseline for affected segment"
for NEXT in "${STAGES[@]}"; do
CURRENT=$(curl -s -H "Authorization: Bearer $FLAGS_API_TOKEN" \
"$FLAGS_API_URL/flags/$FLAG_KEY" | jq -r .rollout.percent)
if [ "$NEXT" -le "${CURRENT:-0}" ]; then
continue
fi
actionbox ask \
"Expand ${FLAG_KEY} to ${NEXT}%?" \
--option approve="Approve expansion to ${NEXT}%" \
--option hold="Hold at ${CURRENT}%" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"${FLAG_KEY}\",\"stage\":\"${NEXT}%\",\"rollback\":\"${ROLLBACK}\"}}]" \
--expires "$(date -u -d '+4 hours' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v+4H '+%Y-%m-%dT%H:%M:%SZ')" \
--on-expire-json "{\"type\":\"resolve\",\"response\":{\"type\":\"single_choice\",\"value\":\"hold\"},\"reason\":\"stage timeout\"}" \
--callback-url https://ops.acme.com/actionbox/flag-callback
doneWhy this beats "just merge the PR"
- Approval is about exposure, not code — the artifact shipped days ago; the decision is about the audience
- Guardrails travel with the request — error rate, latency, and tickets are shown to the approver, not buried in a dashboard
- Rollback is one tap away in context — the approver knows the exact revert before approving
- Same pattern for every flag — LaunchDarkly, Unleash, PostHog, or your own flag service: the gate is just an API call
Try it
- Install the CLI on your release host
- Create a Source →
ACTIONBOX_TOKEN - Replace your "change the percentage" script with the gated version
Create a free Source · CLI + callback reference · Cron jobs that ask before acting