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.
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.
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.shThat'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.shThe 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.shDocker 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.
The first run pulls three Docker images (~200MB total). You'll see Docker's pull progress. Subsequent runs start in seconds.
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.
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.
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."
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."
⚡ 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 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 executionThree decorators. One except. The next unapproved action never executes.
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.
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.
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
- 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
