When people say an AI agent has guardrails, they usually mean it was told to behave. It was given a system prompt with rules, maybe a stern paragraph about risk. That is worth doing, but it is not a control. A control is something the agent cannot override even when it decides to, or is tricked into trying. On a trading account, that distinction is the whole game.
Why a prompt limit is not a control
Suppose your system prompt says never exceed two contracts and never add after a daily loss of five hundred dollars. The model reads that. Most of the time it complies. But a language model produces a distribution of outputs, and the tail of that distribution includes a place_order for five contracts. It can get there through a plain mistake, a misread of the current position, an unusual market that pushes it off its training distribution, or an input crafted to talk it past its instructions. When that call is generated, a prompt limit does nothing, because the prompt was never the thing standing between the order and the account. It was a suggestion the model was free to follow.
This is not a theoretical worry. Prompt injection is now routinely described as the new SQL injection, and for the same reason: untrusted text and trusted instructions travel in the same channel, and the model has no reliable way to tell them apart. Every prompt-hardening mitigation is probabilistic, and researchers publish fresh bypasses within weeks of each new defense. The deeper issue is one of layers. You cannot parameterize a prompt the way you parameterize a SQL query, so a system prompt is simply the wrong place to put a limit that must always hold. Enforcement has to live below the model, in code the model cannot rewrite.
What makes a control enforceable
A guardrail is enforceable when three things are true. It runs in a separate process, so it is not part of the context the model can reason around. It is deterministic, so the same order and the same config always produce the same decision, with no model in the adjudication. And it sits after the tool call and before the platform, so there is no path to the account that skips it. The model’s only power is to propose. The daemon decides.
This is why PitBridge puts the guardrail engine in the local daemon rather than in the agent. The agent calls place_order. The call arrives at the engine. The engine checks it against your limits and returns allow or block. An allowed order continues to the platform; a blocked one stops with a reason. The model never sees around this, because the check does not live where the model lives.
The frozen pipeline: where a control actually lives
A control is only as good as its placement. Every order, whether it comes from an AI agent over MCP or from a plain HTTP request, enters through one method and travels one fixed pipeline. There is no second code path from the agent to the broker, no side door that skips the checks. The pipeline has six stages, and the first stage that refuses an order wins.
| Stage | What it checks | Example block code |
|---|---|---|
| 1. Schema | Request shape and types; quantity is an integer at least 1 | SCHEMA_INVALID, INVALID_QTY |
| 2. Permission | Account mode: read_only, paper, or live, plus the arm-live gate | PERMISSION_READ_ONLY, LIVE_NOT_ARMED |
| 3. Guardrail chain | The twelve controls, evaluated kill-switch first | KILL_SWITCH, MAX_POSITION |
| 4. Human confirm | Holds an allowed order for a person, if configured | HUMAN_CONFIRM_EVERY_LIVE_ORDER |
| 5. Executor submit | Hands an allowed order to the AddOn link | LINK_SEND_FAILED |
| 6. Audit | Appends the request, decision, and outcome to a hash chain | records every decision |
Two stages before the guardrail chain already stop whole classes of bad order. The schema stage rejects a malformed request, including a quantity that is zero or negative: a negative BUY is a disguised SELL that would otherwise slip past a positive per-order cap, so it never gets the chance. The permission stage enforces the account mode. An order to a read_only account returns PERMISSION_READ_ONLY, and an attempt to route live in a build that is not armed returns LIVE_NOT_ARMED. De-risking, meaning cancel, close, and flatten, runs through the same pipeline but is deliberately not gated by the risk guardrails, because getting flat has to work even while entries are halted.
The twelve controls, by what they govern
Version zero ships twelve controls across eleven modules (the position caps live together). They are easier to reason about in three groups, by the question each one answers. Here is the full set with its real reason codes.
| Guardrail (config key) | Governs | Reason code(s) | What it blocks |
|---|---|---|---|
kill_switch | account state | KILL_SWITCH | All new entries while engaged. De-risking stays allowed. |
link_down | account state | LINK_DOWN | Any order while the AddOn link has gone silent. |
instrument_allowlist | pre-trade | INSTRUMENT_NOT_ALLOWED | Any contract that is not on your allowlist. |
position_caps | pre-trade | MAX_CONTRACTS_PER_ORDER | A single order larger than the per-order cap. |
position_caps | pre-trade | MAX_POSITION | An order whose projected net position exceeds the cap. |
trading_window | pre-trade | OUTSIDE_TRADING_WINDOW, NO_TRADE_DAY | An entry outside session hours or on a holiday or early close. |
daily_loss_halt | account state | DAILY_LOSS_HALT | New entries once day P&L hits the floor. Sticky to ET rollover. |
profit_lock | account state | PROFIT_LOCK | New entries once the day is banked above your set level. |
cooldown | account state | COOLDOWN_AFTER_LOSS, COOLDOWN_AFTER_ORDER | An entry inside the cooldown window after a loss or an order. |
rate_limit | account state | MAX_ORDERS_PER_MINUTE, MAX_ORDERS_PER_HOUR, MAX_ORDERS_PER_DAY | Orders past the per-minute, hour, or day count. |
duplicate_protection | account state | DUPLICATE_ORDER | A repeat of the same account, instrument, side, and quantity in the window. |
human_confirm | human in loop | HUMAN_CONFIRM_FIRST_ORDER, HUMAN_CONFIRM_EVERY_LIVE_ORDER, HUMAN_CONFIRM_SIZE_INCREASE | Holds an order for a person until approved; auto-rejects after 60 s. |
Every value in your config is an example you set. The names in the reason-code column are not. They are a fixed contract: the same string appears in the block result the agent reads, in the live event stream, and in the audit log, so a single decision reads the same everywhere.
Pre-trade structural checks: does this order even make sense?
These run on the order itself, independent of what happened earlier in the session. instrument_allowlist refuses any contract you did not list. The two position caps share one module: max_contracts_per_order rejects a single oversized order, and max_position rejects an order whose projected net position after the fill would exceed your cap. Because the check is on the projected position, a risk-reducing order that brings your position back toward the cap stays allowed. trading_window blocks entries outside your session hours and, through a holiday calendar that is early-close aware, on days you should not be entering at all. This is the same class of preset size, price, and instrument check that broker market-access systems have run for years before an order reaches an exchange, which is lineage, not a compliance claim.
Account-state controls: what has already happened today?
These read live account and session state. link_down blocks orders whenever the AddOn link goes silent, because queueing an order against a dead link is how you end up exposed when you thought you were covered. daily_loss_halt stops new entries once the day P&L, realized plus unrealized, reaches your floor, and with day_halt_sticky the account stays shut until the ET session rollover even if the number recovers. profit_lock banks a good day: it exists because a day run far above target can, under a firm’s consistency rule, delay an evaluation from passing, so you may want to stop while ahead. It does not lock in or guarantee any profit. cooldown enforces a pause after a loss or after any order. rate_limit caps orders per minute, hour, and day, which is what stands between you and a runaway retry loop. duplicate_protection refuses a repeat of the same account, instrument, side, and quantity inside a short window, the classic agent double-submit.
Human in the loop: should a person see this first?
human_confirm runs last, and that ordering is deliberate: a person is never paged to approve an order the engine would have refused anyway. When it is on, an allowed order is held as PENDING_CONFIRM, a notifier pings you, and you approve from the CLI. If you do nothing it auto-rejects after sixty seconds, and the daemon re-checks the kill switch, the halts, and the guardrails at confirm time, so an approval cannot resurrect an order the state has since invalidated. It has four modes.
| Mode | When it asks | Reason code |
|---|---|---|
off | Never; an allowed order flows straight to the executor | none |
first_order | Only the first live order of a session | HUMAN_CONFIRM_FIRST_ORDER |
every_live_order | Every live order | HUMAN_CONFIRM_EVERY_LIVE_ORDER |
size_increase | Only when an order increases the position | HUMAN_CONFIRM_SIZE_INCREASE |
A single switch you control that blocks all new entries at once, whatever the agent is doing. De-risking stays allowed, so you can always get flat.
BLOCK all entries. KILL_SWITCH
Refuses any contract that is not on the allowlist you set, before position or state is even considered.
BLOCK NQ 09-26. INSTRUMENT_NOT_ALLOWED
Halts new entries once the day's realized and unrealized loss reaches your floor, sticky to the session rollover.
HALT day -400. DAILY_LOSS_HALT
Holds an allowed order for a person to approve, and auto-rejects it after sixty seconds if no one does.
HOLD 60s. HUMAN_CONFIRM_EVERY_LIVE_ORDER
None of this lives in prose. Each limit is a key in your config.toml, read at startup, and the reason code in a block is the machine-readable echo of the key that refused the order.
[accounts.sim]
nt_account = "Sim101"
mode = "paper" # read_only | paper | live
profile = "eval"
day_halt_sticky = true
[accounts.sim.guardrails]
instruments = ["MES 09-26"] # allowlist: only this contract may trade
max_contracts_per_order = 5 # reject any single order larger than this
max_position = 10 # cap the net position per instrument
daily_loss_halt = 400 # halt the day after -$400
trading_window = { tz = "America/New_York", open = "09:30", close = "16:00" }
max_orders = { per_minute = 6, per_hour = 60, per_day = 200 }
human_confirm = "first_order" # off | first_order | every_live_order | size_increaseExample config. The keys are real; the values are yours to set. A reason code names the exact key that refused an order.
Why the order of the checks is frozen
The chain evaluates in a fixed order, and that order is part of the safety contract, not an implementation detail. kill_switch is always first: an absolute veto should short-circuit everything else, so nothing runs after it once it is engaged. human_confirm is always last, so only a fully-allowed order ever reaches a person. In between, first block wins, and the engine refuses to start if the built-in chain is missing any member, so a control cannot be silently dropped or reordered. Plugins can only be appended between the built-ins and human_confirm. They can add a check, never remove one.
The most important guardrail is the tool that isn’t there
The strongest safeguard on this list is not a check at all. It is an absence. The agent’s entire surface is nine MCP tools: five reads (get_accounts, get_positions, get_orders, get_account_state, get_guardrail_status) and four actions (place_order, cancel_order, close_position, flatten_account). There is no tool to release the kill switch, no tool to arm live trading, and no tool to change a threshold. The configuration and the kill and arm controls live on a separate operator-only CLI that the model’s context never touches.
This is the concrete meaning of a control an LLM cannot override. It is not that the model is well-behaved or well-prompted. It is that the capability to weaken its own limits is absent from its world. You can ask it to raise max_contracts to a hundred; it has no function to call.
Engaging the brakes: the kill switch and de-risking
The kill switch is file-backed. Its presence as a file under PITBRIDGE_HOME means engaged, so a person can touch that file and stop all new entries even if the daemon is wedged and answering nothing else. You can also engage it by CLI or REST. Release is CLI-only, with pitbridge unkill, and there is no remote or agent path to lift it. Under a kill, the asymmetry is the whole point: new entries return KILL_SWITCH, but cancel, close, and flatten stay allowed, so you can always get flat. The same asymmetry holds under a daily-loss halt. day_halt_sticky, on by default, keeps a halted account shut until the ET rollover, and the optional flatten_on_halt will flatten the account the moment the halt trips rather than leaving the position for you to close by hand.
# the agent proposes an order that breaks a configured limit place_order account=sim instrument="MES 09-26" side=BUY qty=20 # the engine adjudicates. the model has no say here. BLOCKED reason_code=MAX_CONTRACTS_PER_ORDER order not sent. adjust the order or the rule.
Example decision. Field names and the reason code match the daemon; the values are yours to configure.
You can read every decision: the audit chain
Enforceability includes provability. A control you cannot inspect after the fact is a control you are asked to take on faith. Every request, decision, and outcome is written to an append-only, hash-chained JSONL log. pitbridge audit why <order_id> returns the original request, the guardrail decision with its reason code, and the submit result, so any single order can be explained without guesswork. pitbridge audit verify --anchor proves the whole chain is intact and, crucially, catches someone deleting the tail of the log, which a bare hash chain cannot see on its own. A control you cannot audit is a control you cannot trust.
# ask the log why one order stopped
pitbridge audit why ord_7f3a --log audit.jsonl
request place_order account=sim MES 09-26 BUY qty=20
decision BLOCK reason_code=MAX_CONTRACTS_PER_ORDER
outcome not sent
chain verified (hash-linked to previous entry)Example output. Real command and reason code. Every decision, allow or block, lands in the same tamper-evident chain.
What this does not promise
Honesty about limits is part of the design. A guardrail enforces the rules you configure. It does not predict the market, it does not keep an account funded, and it does not prevent losses. An allowed order can still lose, because the market can move against a position that was fully within your limits. What the engine promises is narrow and real: an order that breaks a configured limit does not reach your account, and every decision is logged.
If you want checks like these between your agent and your account, tell us your platform on the waitlist. The full set is on the guardrails page, the pillar on trading AI guardrails covers why agents need them, and the security model explains where each part runs. PitBridge is trading infrastructure, not financial advice: it enforces the limits you configure and does not promise any trading outcome.