Cycles Action Authority Demo
April 26, 2026 · View on GitHub
An agent can use CRM, notes, and email — but Cycles decides which actions are allowed before execution.
Same agent. Same tools. The next unapproved action never executes.
The scenario
A support automation agent handles customer case #4782 (invoice discrepancy). The agent's workflow has four steps:
- Read case — load case details from the CRM
- Append internal note — log an internal investigation note
- Update CRM status — change the case from Open to Investigating
- Send customer email — reply to the customer with an update
Steps 1–3 are internal, low-risk operations. Step 4 is a consequential, customer-facing action. In production, you want a human to approve outbound emails before they go out. Cycles enforces this by setting a zero-dollar budget for unapproved toolsets.
Without Cycles, all four steps execute — including the email. With Cycles, the server returns 409 BUDGET_EXCEEDED before the email send can proceed, and the agent reports: "Email blocked — not approved for autonomous execution. Escalated to human review."
No real CRM, email service, or ticketing system is used. All tools are mocked locally. The action authority is real.
Run it
Prerequisites: Docker Compose v2+, Python 3.10+, curl
git clone https://github.com/runcycles/cycles-agent-action-authority-demo
cd cycles-agent-action-authority-demo
python3 -m venv .venv && source .venv/bin/activate
pip install -r agent/requirements.txt
./demo.sh
That's it. The script starts the Cycles stack (Redis + server + admin), provisions a tenant with toolset-scoped budgets, then runs both modes back to back.
Run a single mode:
./demo.sh unguarded # without Cycles (all actions execute)
./demo.sh guarded # with Cycles (email blocked)
./demo.sh both # both back to back (default)
Re-runs just work — the script resets the stack automatically to ensure a clean state.
Stop the stack when done:
./teardown.sh
Windows (WSL)
The demo runs on Windows 11 via WSL. Install Docker Desktop for Windows with the WSL 2 backend enabled (the default), then inside your WSL terminal:
sudo apt update && sudo apt install -y python3-full curl
git clone https://github.com/runcycles/cycles-agent-action-authority-demo
cd cycles-agent-action-authority-demo
python3 -m venv .venv && source .venv/bin/activate
pip install -r agent/requirements.txt
./demo.sh
Docker Desktop shares the daemon between Windows and WSL automatically — no extra configuration needed.
Note: Ubuntu 23.04+ requires
python3-full(not justpython3) so that venvs get their own pip. Without it, evenpipinside a venv hits the PEP 668 "externally-managed-environment" error.
First run notes
The first run pulls three Docker images (~200MB total). You'll see Docker's pull progress. Subsequent runs start in seconds.
Troubleshooting
JedisConnectionException: Failed to create socket / UnknownHostException: redis
You started the stack with docker compose up directly (instead of ./demo.sh) on top of a previous run, and the cycles-server container ended up on a stale network where the redis service no longer exists. Reset and restart:
docker compose down -v && docker compose up -d
./demo.sh does this automatically before every run, so the script path doesn't hit this.
What you'll see

The GIF runs both modes back to back in ~30 seconds: the unguarded
segment sends the customer email unchecked, then "MODE 2: WITH CYCLES"
runs the same workflow and the email is blocked at the send-email
toolset (zero-dollar budget → 409 BUDGET_EXCEEDED). The end card
contrasts 4 / 4 actions executed against 3 / 4 executed · 1 blocked
and shows the per-toolset authority decisions.
For homepage embedding, also available as demo.mp4 (H.264) and
demo.webm (VP9). All assets are recorded at 2000×1200 (2× retina
density) so text stays crisp on HiDPI displays even when the browser
scales them down to ~1000×600 for layout. Both videos autoplay inline:
<video autoplay loop muted playsinline poster="demo-action-authority-poster.png">
<source src="demo.webm" type="video/webm">
<source src="demo.mp4" type="video/mp4">
<img src="demo.gif" alt="Cycles Action Authority Demo">
</video>
demo-action-authority-poster.png is the last-frame summary card —
used as the poster attribute so autoplay-blocked browsers, slow-
network first paint, and social/SEO link previews all show the
SENT vs DENY contrast even before the video plays.
Without Cycles
All four actions execute with green checkmarks — including the customer email. The final panel reads:
"All actions executed — including the customer email. In production: no authorization gate existed. The email went out unchecked."
With Cycles (action authority)
The first three actions execute with green checkmarks. The fourth (send email) shows a red X. The final panel reads:
"Cycles blocked the customer email before it was sent. Internal actions proceeded. Customer-facing action not approved for autonomous execution."
Expected output
⚡ Cycles — Action Authority Demo
Resetting stack (clean state)...
Starting Cycles stack...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MODE 1: Without Cycles
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
╭───────────────── Support Case #4782 ──────────────────╮
│ Customer: Acme Corp (jane@acme.com) │
│ Subject: Invoice shows \$847, contract \$720 │
│ Agent: support-bot │
│ Mode: UNGUARDED │
╰───────────────────────────────────────────────────────╯
╭───────────────── Action Log ──────────────────────────╮
│ │
│ ✓ read_case │
│ Loaded case #4782 — Acme Corp │
│ │
│ ✓ append_internal_note [internal-notes] │
│ Billing discrepancy: \$847 vs \$720. Investigating. │
│ │
│ ✓ update_crm_status [crm-updates] │
│ Status: Open → Investigating │
│ │
│ ✓ send_customer_email [send-email] │
│ Email sent to jane@acme.com │
│ │
╰───────────────────────────────────────────────────────╯
╭───────────────── Result — UNGUARDED ──────────────────╮
│ All actions executed — including the customer email. │
│ 4 actions approved · 0 actions blocked │
╰───────────────────────────────────────────────────────╯
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MODE 2: With Cycles (action authority)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
╭───────────────── Support Case #4782 ──────────────────╮
│ Customer: Acme Corp (jane@acme.com) │
│ Subject: Invoice shows \$847, contract \$720 │
│ Agent: support-bot │
│ Mode: GUARDED │
╰───────────────────────────────────────────────────────╯
╭───────────────── Action Log ──────────────────────────╮
│ │
│ ✓ read_case │
│ Loaded case #4782 — Acme Corp │
│ │
│ ✓ append_internal_note [internal-notes] │
│ POST /v1/reservations → 200 ALLOW │
│ Billing discrepancy: \$847 vs \$720. Investigating. │
│ │
│ ✓ update_crm_status [crm-updates] │
│ POST /v1/reservations → 200 ALLOW │
│ Status: Open → Investigating │
│ │
│ ✗ send_customer_email [send-email] │
│ POST /v1/reservations → 409 BUDGET_EXCEEDED │
│ Email blocked — escalated to human review. │
│ │
╰───────────────────────────────────────────────────────╯
╭───────────────── Result — GUARDED ────────────────────╮
│ Cycles blocked the customer email before it was sent. │
│ 3 actions approved · 1 action blocked │
╰───────────────────────────────────────────────────────╯
Demo complete.
Swagger UI: http://localhost:7878/swagger-ui.html
Admin UI: http://localhost:7979/swagger-ui.html
Re-run: ./demo.sh
Stop stack: ./teardown.sh
The code change
The diff between agent/unguarded.py and agent/guarded.py is:
# --- Import the SDK ---
from runcycles import BudgetExceededError, CyclesClient, CyclesConfig, cycles, set_default_client
# --- Initialize the client ---
config = CyclesConfig(
base_url=os.environ["CYCLES_BASE_URL"],
api_key=os.environ["CYCLES_API_KEY"],
tenant=os.environ["CYCLES_TENANT"],
agent="support-bot",
workspace="default",
app="default",
workflow="default",
)
set_default_client(CyclesClient(config))
# --- Add three decorators with toolset scoping ---
@cycles(estimate=COST_PER_ACTION_MICROCENTS, action_kind="tool.notes", action_name="append-note", toolset="internal-notes")
def append_internal_note(...): ...
@cycles(estimate=COST_PER_ACTION_MICROCENTS, action_kind="tool.crm", action_name="update-status", toolset="crm-updates")
def update_crm_status(...): ...
@cycles(estimate=COST_PER_ACTION_MICROCENTS, action_kind="tool.email", action_name="send-reply", toolset="send-email")
def send_customer_email(...): ...
# --- Catch the budget exception ---
except BudgetExceededError:
# email blocked — not approved for autonomous execution
Three decorators. One except. The next unapproved action never executes.
How it works
The key mechanism is toolset-scoped budgets. The provisioning script creates budgets for:
| Toolset | Budget | Result |
|---|---|---|
internal-notes | $1.00 | ✓ Agent can append notes |
crm-updates | $1.00 | ✓ Agent can update CRM |
send-email | $0 | ✗ Zero budget → 409 |
When the @cycles decorator tries to reserve budget in a scope with a zero-dollar budget, the Cycles server returns 409 BUDGET_EXCEEDED. The decorator raises BudgetExceededError, and the action never executes.
This means you control agent capabilities at the budget provisioning level — no code changes needed to approve or revoke a tool. Increase the send-email budget and the agent can send emails. Set it to zero and the agent can't.
Why this matters
Static tool allowlists can't adapt at runtime. API keys grant blanket access. Role-based permissions don't distinguish between "the agent CAN send email" and "this specific run SHOULD send email." Cycles enforces a runtime decision before each consequential action — without changing agent code.
Next steps
After running the demo, explore how to add Cycles to your own application:
- What is Cycles? — understand the problem and the solution
- End-to-End Tutorial — zero to a working budget-guarded app in 10 minutes
- Adding Cycles to an Existing App — incremental adoption guide
- Full Documentation — complete docs at runcycles.io
Links
- Protocol: https://github.com/runcycles/cycles-protocol
- Server: https://github.com/runcycles/cycles-server
- Python:
pip install runcycles - Java:
io.runcycles:cycles-client-java-spring - Node.js:
npm install runcycles