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 1000000mints 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
transferthat loses funds, because you don’t writetransferat all. - Actors compose with tokens via host functions, not message calls:
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).
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/core/07-timers-and-automation
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:
- Block height, never wall-clock.
schedule_afterresolves the fire height asget_block_height() + delay_blocks, because wall-clock time is nondeterministic. Useget_timestamp_ms()only for display, never for scheduling logic.cowboy_sdk.timer.schedule(height, payload, handler=...)is the absolute-height form of the same call when you already hold a target height —schedule_afterdelegates to it, so they share one encoder. (Don’t confuse that method with thetimer.scheduleentitlement id both forms require.) - Handler routing. Naming a handler wraps the payload in the routing
envelope so the fire invokes that method rather than the default
handle_timer— one actor can schedule several distinct callbacks. The envelope is the SDK’s to build; don’t hand-roll it, and don’t reach pastruntimeto the host. - The delivered payload is not the scheduled payload. The node re-serializes
the envelope’s inner payload as a JSON list of byte values, so the handler
reads its data back with
codec.decode(bytes(json.loads(payload))). The examples wrap that in a small_timer_payloadhelper. fee_payermay only be the actor or the tx sender. Third-party sponsorship is rejected by the host, soschedule_after(..., fee_payer=sender)shifts a fire onto the caller only when an external transaction arms it. A self-rescheduling loop re-arms from inside its own fire, where the sender is the actor itself — so every fire after the first is actor-funded whatever you pass. Budget for that: keep the actor topped up. A timer whose fee-payer can no longer fund a fire self-destructs, so the loop stops cleanly instead of erroring forever. (System actors paying themselves are a special case — the reserved0x01..=0xFFband — every address belowRESERVED_SYSTEM_ADDRESS_LIMIT = 0x100— is rejected as afee_payerunless it is the scheduling actor’s own address.)- 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.
- The
awaitis a suspension point, not a thread block. Atawait 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 theawait. You do not hand-write the resume method —@runner.continuationgenerates 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 thecapture()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
awaitinside 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_refreshwrites state and emits an event — it runs in the resume frame like any handler, so the normal gas/determinism rules apply.
runner.agent(...) (LLM tool-calling over
mounted CBFS volumes) and the llm_chat example — await an off-chain result,
resume deterministically when it lands.
