Skip to main content
The example index lists 30+ runnable actors. This page goes deeper on three that each teach a distinct Cowboy concept, explaining why the code is shaped the way it is. Read these alongside Your First Actor and the SDK reference.

1. CIP-20 tokens — examples/01-tokens

The key idea: CIP-20 tokens are not actor contracts. Unlike ERC-20 (a Solidity contract per token), a Cowboy token is a first-class chain primitive — create / transfer / approve / mint / burn are native validator instructions, and balances live in a global Token Registry, not in any actor’s storage. That design choice has consequences worth internalizing:
  • No contract to deploy. cowboy token create --name "My Token" --symbol MTK --decimals 18 --initial-supply 1000000 mints a token in a single system tx; there is no bytecode and no per-token storage to rent.
  • The validator enforces invariants (overflow, underflow, frozen accounts) — you cannot write a buggy transfer that loses funds, because you don’t write transfer at all.
  • Actors compose with tokens via host functions, not message calls:
    from cowboy_sdk import runtime
    token_id = runtime.token_create(
        name=b"My Token", symbol=b"MTK", decimals=18,
        initial_supply=1_000_000, max_supply=None,
        transfer_hook=None,        # or an actor address to gate transfers
        metadata_uri=None,
    )
    runtime.token_transfer(token_id, to=recipient, amount=500)
    
    These run inside the PVM and settle atomically with the rest of the handler — the basis for DeFi-style composability (see examples/02-liquidity-pools).
When to reach for transfer_hook: it names an actor the validator calls on every transfer, letting you implement allowlists / fee-on-transfer / freezes without owning the token logic. It is the one place token behavior becomes programmable — use it sparingly, because it runs on every transfer and is metered against the transfer’s gas.

2. Self-rescheduling timers — examples/21-pure-timer-scheduler

This actor advances a counter purely by rescheduling itself — no external keeper, no cron. It is the canonical pattern for “do X every N blocks.” The mechanics that matter:
def _schedule_timer(delay_blocks, handler, payload):
    fire_height = int(runtime.get_block_height()) + int(delay_blocks)
    timer_payload = {"_handler": handler, **payload}
    payload_bytes = json.dumps(timer_payload).encode("utf-8")
    sender = runtime.get_sender()
    if sender:
        return runtime.schedule_timer_ex(fire_height, payload_bytes, fee_payer=sender)
    return runtime._host().schedule_timer(fire_height, payload_bytes)
  • Block height, never wall-clock. Timers fire at a block height (get_block_height() + delay), because wall-clock time is nondeterministic. Use get_timestamp_ms() only for display, never for scheduling logic.
  • _handler routing. A timer payload of the form {"_handler": "<name>", ...} makes the timer invoke that named handler on fire (rather than the default handle_timer) — so one actor can schedule several distinct callbacks.
  • fee_payer is a deliberate choice. schedule_timer_ex(..., fee_payer=sender) pre-charges each fire to the original caller, so the actor doesn’t silently drain its own balance keeping a loop alive. A timer whose fee-payer can no longer fund a fire self-destructs — the loop stops cleanly instead of erroring forever. (System actors paying themselves are a special case — the reserved 0x01..=0x0F band is rejected as a fee_payer.)
  • One-shot, so re-arm explicitly. Each timer fires once; to keep a cadence, the fire handler schedules the next one. There is no “recurring timer” flag — the re-arm is in your handler, which keeps the control flow visible.

3. Runner continuations — examples/20-minimal-runner-continuation

Off-chain work (an LLM call, an HTTP fetch) can’t block a handler — the block must finalize. Cowboy’s answer is the continuation: you write straight-line async/await code, and the SDK compiles it into a resumable state machine.
@runner.continuation
async def _run_refresh_job(self, payload):
    request = _parse(payload)
    ctx = capture()                       # variables that must survive the await
    ctx.request_id = int(request.get("request_id", 0))

    result = await runner.http(           # suspends here; the block finalizes
        request.get("source_url", DEFAULT_SOURCE_URL),
        method="GET", timeout_ms=5000,
    )
    return self._finalize_refresh(ctx.request_id, result)   # runs when the runner replies
What’s actually happening — and the rules that fall out of it:
  • The await is a suspension point, not a thread block. At await runner.http(...) the handler returns; the job is dispatched to an off-chain runner; a later block delivers the result and the SDK resumes the code after the await. You do not hand-write the resume method — @runner.continuation generates the state machine at import time.
  • capture() is mandatory for anything crossing the await. Local variables don’t survive suspension automatically — only fields you stash on the capture() context (ctx.request_id) are serialized into the continuation state and available on resume. Forgetting this is the #1 continuation bug.
  • The compiler enforces determinism limits (see the PVM reference): no more than 8 sequential awaits per function, no await inside a nested function or a bare loop (use @bounded_loop), no generators. These exist because the state machine must be finite and serializable.
  • Finalize is ordinary, synchronous code. _finalize_refresh writes state and emits an event — it runs in the resume frame like any handler, so the normal gas/determinism rules apply.
This is the same machinery behind runner.agent(...) (LLM tool-calling over mounted CBFS volumes) and the llm_chat example — await an off-chain result, resume deterministically when it lands.