User bots
Users author the bots that trade. Their code and their config are untrusted input, in the same sense that an HTTP request body is untrusted input. This page defines the contract they write against, the sandbox it runs in, and the budgets it cannot exceed.
Two contracts, not one. Bot<I,O>
is the contract for bots we write. It is broad, and its rules
("must not submit orders directly") are conventions enforced by code review, which is
appropriate for code we own. UserStrategy is the contract for
bots users write. Its rules are enforced by the runtime, because there is no
code review.
Two authoring tiers
| Tier | Author writes | Expressiveness | Review | Status |
|---|---|---|---|---|
| Tier 1 — declarative | rules.yaml |
Bounded. Conditions, entries, take-profit rungs, caps, kill-switch bindings. | Automatic. Schema validation plus a static bounds check. | v1 target |
| Tier 2 — sandboxed code | A UserStrategy module |
Arbitrary decision logic within the sandbox. | Automatic static checks, then a shadow soak before it can go live. | later tier |
Build Tier 1 first. It covers the majority of what users ask for, and it is the only tier where the safety argument is straightforward: a declarative rule set cannot execute arbitrary code, so the sandbox is a bounds check rather than an isolate.
Tier 1 — rules.yaml
The user's bot definition. Version-controlled per user, diffable, and the artefact an audit refers to. Every field maps to a guardrail that already exists in the library, which is the point: the authoring format cannot express something the chain cannot check.
// rules.yaml — one user bot
version: 1
name: nfl-favourite-fade
market_scope:
venue: polymarket
cluster: nfl-2026-regular # or an explicit market id list, cap 50
exclude_resolving_within: 2h
entry:
when: # ALL must hold; each maps to a signal service
- signal: implied_prob
of: favourite
above: 0.72
- signal: book_depth_usd
at_touch: { min: 2500 }
- signal: minutes_to_kickoff
between: [30, 240]
side: sell
size:
type: pct_of_bankroll # never an absolute — bankroll is the user's
value: 2.0
max_usd: 500 # hard ceiling, must be ≤ platform per-bot cap
scale_out: # take-profit rungs, evaluated in order
- at_pnl_pct: 8 sell_pct: 40
- at_pnl_pct: 15 sell_pct: 40
- at_pnl_pct: 25 sell_pct: 20
risk: # user tunes DOWN from platform floor, never up
max_open_positions: 6
max_exposure_pct: 12
max_drawdown_pct: 10 # breach ⇒ bot pauses, user is notified
max_correlated_exposure_pct: 18 # see intel.crossmarketgraph
kill_switch:
bind: # conditions that stop THIS bot immediately
- venue_degraded
- oracle_disputed
- user_manual
on_trigger: flatten # flatten | hold | cancel_open_only
notify:
on: [entry, scale_out, guardrail_reject, kill_switch, drawdown_warning]
channel: [in_app, email]
Validation rules
- Every
signal:name must resolve to a registered signal service. Unknown names are a hard reject at save time, not a runtime surprise. This is what keeps the authoring format honest. - Sizes are relative, with an absolute ceiling.
pct_of_bankrollis the only size type. An absolute-only size is rejected because bankroll changes and the user will not revisit the file. risk:values may only tighten. A value looser than the platform floor is clamped and the user is told it was clamped. Silent clamping is forbidden — it teaches users the file does not mean what it says.market_scopeis capped. A cluster or an explicit list of at most 50 markets. Unbounded scope is how one bad rule becomes a portfolio event.- No time-of-day or clock arithmetic in user space. Anything time-based resolves through the platform clock so backtest and live agree.
Tier 2 — the UserStrategy contract
Deliberately smaller than Bot<I,O>. Note what is absent:
no authority field (a user bot has exactly one authority level,
propose), no setMode (the platform owns mode), no
emit (the platform emits on the user's behalf so envelopes cannot
be forged).
interface UserStrategy {
// Identity — assigned by the platform, not the author
readonly userBotId: string;
readonly version: string;
// The only hook. Pure. Deterministic. Budgeted.
decide(
input: UserStrategyInput, // pre-filtered, pre-validated snapshot
ctx: UserStrategyContext
): Promise<Proposal | null>; // null = do nothing, the common case
}
interface UserStrategyInput {
markets: ReadonlyArray<MarketSnapshot>; // only markets in declared scope
positions: ReadonlyArray<Position>; // only THIS bot's positions
signals: Readonly<Record<string, Signal>>; // only declared signals
}
interface UserStrategyContext {
now(): number; // platform clock. Date.now() is unavailable
bankroll(): number; // the slice allocated to this bot, not the wallet
log(msg: string): void; // rate-limited, goes to the user's own log only
// Deliberately absent: fetch, require, process, venue clients, signers,
// other bots' positions, other users' anything, secrets, the filesystem.
}
// A Proposal is NOT an order. It is a request to be considered.
interface Proposal {
marketId: string; // must be in declared scope or hard reject
side: "buy" | "sell";
sizePctOfBankroll: number; // platform converts to absolute
limitPrice?: number;
rationale: string; // shown to the user, required, max 280 chars
}
Sandbox budgets
Enforced by the runtime, not by review. Every breach emits a reason code and is visible to the user on their own bot page.
| Budget | Limit | On breach | Reason code |
|---|---|---|---|
CPU per decide | 50 ms | Abort the call, treat as null, count a strike | USER_DECIDE_TIMEOUT |
| Memory per isolate | 64 MB | Kill the isolate, pause the bot | USER_MEMORY_EXCEEDED |
| Decisions per minute | 60 | Throttle, then pause on sustained breach | USER_RATE_LIMITED |
| Proposals per hour | 30 | Reject further proposals, notify the user | USER_PROPOSAL_FLOOD |
| Consecutive strikes | 5 | Quarantine the bot, require the user to re-enable | USER_BOT_QUARANTINED |
| Log volume | 200 lines / min | Drop and mark the log truncated | USER_LOG_TRUNCATED |
Budgets are per user bot, not per user. One user running twenty bots gets twenty budgets — which is why the per-account aggregate cap in RiskGate matters independently.
Determinism, because backtests have to mean something
A user bot must produce the same proposals given the same input. If it does not, its
backtest is not evidence and its shadow soak proves nothing. Enforced by running every
decide twice on a sample of ticks and comparing:
- No wall clock.
Date.now,performance.now, and timers are not in the isolate. - No unseeded randomness.
Math.randomis replaced with a seeded PRNG derived from(userBotId, tick). - No I/O. No network, no filesystem, no environment.
- Iteration order over
input.marketsis stable and documented.
Lifecycle of a user bot
- Authored. User writes
rules.yamlin the builder, or uploads a Tier-2 module. Schema validation and bounds checks run on save. - Backtested. Runs against retained order-book depth. Mid-price-only results are not accepted as evidence. The user sees fills, fees, and slippage.
- Shadow. Runs live against real data, proposals evaluated by the full guardrail chain, nothing signed. The user sees exactly what would have happened, including every reject.
- Live, capped. Signing enabled, but the per-bot cap starts low regardless of what the user asked for, and ratchets up on clean history.
- Live. The user's configured caps apply, still floored by the platform.
- Paused or quarantined. By the user, by a drawdown breach, by a strike count, or by a kill-switch binding. Re-enabling is always an explicit user action.
The state names deliberately mirror the promotion ladder we use for our own bots. Same vocabulary, same gates, different owner.
Forbidden, and enforced rather than requested
- Importing or receiving a venue client, signer, session key, or API credential.
- Reading another bot's positions, another user's anything, or platform-wide aggregates.
- Emitting a
ReportEnvelope. The platform emits; the user bot supplies a rationale string. - Constructing a market id at runtime that was not in the declared scope.
- Catching an error and returning a default proposal. A throwing
decideis treated asnulland counts a strike. - Any absolute size that is not accompanied by a percentage-of-bankroll basis.
the four invariants · these are not negotiable
- 1
The platform never holds user funds.
Keys stay in the user's wallet. There is no platform-controlled omnibus account, no internal ledger of custodied balances, and no code path that can move a user's assets without a signature the user authorised. If a feature needs custody to work, it does not ship.
- 2
A user bot never reaches the venue directly.
User-authored logic produces a proposal. It never holds a venue client, an API key, or a signer. Exactly one component signs and submits, and it is ours.
- 3
Guardrails cannot be configured away.
The user tunes thresholds inside bounds we set. The user cannot remove a guardrail from the chain, reorder the chain, or lower a hard cap below the platform floor.
- 4
AI never holds Trade authority.
Model output is advisory. An AI suggestion enters the same RiskGate as any bot intent and is subject to the same rejects. There is no path from a model response to a signed order that skips the chain.
If a design requires breaking one of these, the design is wrong. Escalate before writing code — do not work around it. See RiskGate and the signing model.