# Write a pre-trade hook for an AI trading agent

> Build a small Python service that checks every AI agent order before it reaches the broker: verify the HMAC signature, apply size, symbol, hours and daily loss rules, and answer.

Source: https://tickwire.io/blog/write-a-pre-trade-hook/

Tickwire blog, published 2026-10-20. General information, not investment advice.

A pre-trade hook is a small web service you run yourself. Before an AI agent's order reaches the broker, the platform sends the order to your hook and waits for an answer: allow it, deny it, or hold it for a person to approve. The agent cannot skip the hook, cannot argue with it, and cannot see its code.

That makes the hook the right home for hard risk rules. A line in a prompt such as "never trade more than half a lot" is a request to a model. The same rule in a hook is a check that runs on every order, whatever the model decided.

This post builds a working hook in Python, under 100 lines, with four example rules: a maximum volume, a symbol list, trading hours in UTC and a daily loss limit. It also covers the parts people get wrong: signatures, timeouts and what happens when the hook is down. The field and header names come from Tickwire's hook format, but the design applies to any service of this kind.

## How the round trip works

For each agent order, Tickwire sends an HTTPS `POST` to your hook's URL. The body is JSON and the request carries four headers:

| Header | Value |
|---|---|
| `X-Tickwire-Event` | `pretrade.decision_request` |
| `X-Tickwire-Hook-Id` | Your hook's id, `hk_...` |
| `X-Tickwire-Timestamp` | Unix time in seconds |
| `X-Tickwire-Signature` | `sha256=` followed by a hex HMAC-SHA256 |

The main fields of the body:

- `request_id` (`dr_...`): the id of this decision request. Your answer must echo it.
- `deadline_ms`: how long Tickwire will wait for you. The default is 2,000 ms.
- `account_id` and `platform` (`mt4` or `mt5`).
- `actor`: who asked, with `kind`, `id` and `label`, for example `agent:eurusd-scout`.
- `reason`: the agent's own sentence about why it wants this trade.
- `order`: the order exactly as the agent sent it, with `actionType`, `symbol`, `volume` (in lots), `openPrice`, `stopLoss`, `takeProfit`, `positionId` and so on.
- `position`: the position the order acts on, when it acts on one, else `null`.
- `account`: a fresh read from the terminal just before the call, with `balance`, `equity`, `margin`, `free_margin`, `currency`, `leverage`, `open_positions`, `source` and `taken_at`. If the terminal could not answer in time, `account` is `null` and `account_error` says why. It is never guessed.
- `idempotency_key` and `hold_id_if_held`, which you need only if you use holds.

Your hook answers with HTTP 200 and a JSON body:

```json
{ "request_id": "dr_...", "decision": "deny", "message": "daily loss limit reached" }
```

`decision` is `allow`, `deny` or `hold`. The `message` (cut at 500 characters) is shown to the agent and to you. The answer must be signed the same way as the request, with its own `X-Tickwire-Timestamp` and `X-Tickwire-Signature` headers. Echoing `request_id` ties your answer to this one order, so a recorded `allow` can't be replayed for a different one.

## The signature, exactly

The signature is HMAC-SHA256 (a keyed hash: only someone holding the secret can produce it) over three things joined together: the timestamp as text, a dot, and the raw body bytes.

```text
signature = "sha256=" + hex( HMAC_SHA256( secret, "<timestamp>.<raw body>" ) )
```

Three rules keep this correct:

1. **Sign and verify the raw bytes.** Don't parse the JSON and dump it again before checking. Key order and spacing change, and the signature will no longer match.
2. **Compare in constant time.** Use `hmac.compare_digest`, not `==`. A plain comparison can leak, through timing, how many characters matched.
3. **Reject stale timestamps.** Tickwire refuses answers whose timestamp is more than 5 minutes off its clock. Apply the same window to requests, so a captured request can't be replayed later. Keep your server's clock synced.

The secret starts with `twhs_` and is shown once, when you register the hook. Keep it in an environment variable or a secret store, never in source code.

## The hook in Python

This uses FastAPI. Any framework works if you can read the raw body and set response headers.

```python
import hashlib, hmac, json, os, time
from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response

SECRET = os.environ["HOOK_SECRET"].encode()  # twhs_..., shown once at registration
MAX_SKEW_S = 300

# Example rules. Yours will differ.
MAX_LOTS = 0.50
SYMBOLS = {"EURUSD", "GBPUSD", "USDJPY"}
OPEN_HOUR_UTC, CLOSE_HOUR_UTC = 7, 20   # new exposure 07:00 to 19:59 UTC only
MAX_DAILY_LOSS = 0.03                   # 3% below the day's first equity reading

OPENING = {
    "ORDER_TYPE_BUY", "ORDER_TYPE_SELL",
    "ORDER_TYPE_BUY_LIMIT", "ORDER_TYPE_SELL_LIMIT",
    "ORDER_TYPE_BUY_STOP", "ORDER_TYPE_SELL_STOP",
    "ORDER_TYPE_BUY_STOP_LIMIT", "ORDER_TYPE_SELL_STOP_LIMIT",
}

app = FastAPI()
day_start_equity: dict[tuple[str, str], float] = {}  # (account_id, UTC date) -> equity


def sign(ts: str, raw: bytes) -> str:
    mac = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256)
    return "sha256=" + mac.hexdigest()


def verified(headers, raw: bytes) -> bool:
    ts = headers.get("X-Tickwire-Timestamp", "")
    sig = headers.get("X-Tickwire-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW_S:
        return False
    return hmac.compare_digest(sign(ts, raw), sig)


def answer(request_id: str, decision: str, message: str) -> Response:
    body = json.dumps({"request_id": request_id, "decision": decision,
                       "message": message}).encode()
    ts = str(int(time.time()))
    return Response(content=body, media_type="application/json",
                    headers={"X-Tickwire-Timestamp": ts,
                             "X-Tickwire-Signature": sign(ts, body)})


def decide(req: dict) -> tuple[str, str]:
    order = req.get("order") or {}
    action = order.get("actionType", "")

    # Closing, cancelling and modifying reduce or reshape risk: let them through.
    if action not in OPENING:
        return "allow", "not a new position"

    symbol = order.get("symbol", "")
    if symbol not in SYMBOLS:
        return "deny", f"{symbol} is not on this agent's symbol list"

    volume = float(order.get("volume") or 0)
    if volume <= 0 or volume > MAX_LOTS:
        return "deny", f"volume {volume} lots is outside 0 to {MAX_LOTS} lots"

    now = datetime.now(timezone.utc)
    if not OPEN_HOUR_UTC <= now.hour < CLOSE_HOUR_UTC:
        return "deny", f"no new positions outside {OPEN_HOUR_UTC}:00 to {CLOSE_HOUR_UTC}:00 UTC"

    account = req.get("account")
    if account is None or account.get("equity") is None:
        return "deny", "no fresh account read, so the loss limit can't be checked"
    equity = float(account["equity"])
    start = day_start_equity.setdefault((req["account_id"], now.date().isoformat()), equity)
    if equity <= start * (1 - MAX_DAILY_LOSS):
        return "deny", f"daily loss limit reached: equity {equity:.2f} vs {start:.2f} at day start"

    return "allow", "within limits"


@app.post("/decide")
async def decide_endpoint(request: Request) -> Response:
    raw = await request.body()
    if not verified(request.headers, raw):
        return Response(status_code=401)  # treated as no valid answer: the order is not sent
    req = json.loads(raw)
    if req.get("type") != "pretrade.decision_request":
        return Response(status_code=400)
    decision, message = decide(req)
    return answer(req["request_id"], decision, message)
```

Run it with `uvicorn hook:app` behind HTTPS. The hook URL must use `https`.

## Notes on the rules

**Let reductions through.** Closing a position, cancelling a pending order or tightening a stop lowers risk. A hook that denies those can trap you in a bad position, so the example allows every action that is not a new order. If you want to stop an agent from widening stops, add a rule for `POSITION_MODIFY` that compares the new `stopLoss` with the one in `position`.

**Symbol names vary by broker.** Many brokers add a suffix, so EURUSD may be `EURUSD.r` or `EURUSDm` on your account. Put the exact names in the list.

**Hours are in UTC, on your clock.** The example uses the hook server's own UTC time, not broker server time. Broker time is often a different zone and changes with daylight saving. If your rules follow the broker's trading day, convert deliberately. See [broker server time is not UTC](https://tickwire.io/blog/broker-time-is-not-utc/).

**The daily loss rule needs a starting point.** The request carries equity now, not equity at the start of the day. The example stores the first equity it sees each UTC day, which has two weak spots: it is lost on restart, and if the first order of the day comes after a loss, the baseline is already low. In a real hook, record equity at your day boundary from a scheduled job and keep it in a database.

**No account read means no new exposure.** When `account` is `null`, the example denies new positions instead of guessing. Never treat a missing read as zero loss.

## Timeouts, holds and failing closed

Your hook has a time budget, `timeout_ms`, which defaults to 2,000 ms and can be set from 100 to 10,000. Tickwire treats anything other than a valid, signed, on-time answer as no answer: a timeout, a connection error, a status other than 200, a bad or missing signature, a stale timestamp, a `request_id` that doesn't match, or a `decision` it doesn't recognise. By default the hook **fails closed**: the order is not sent and the agent gets `hook_unavailable`.

There is a `fail_mode: "open"` setting that sends orders when the hook has an outage (a timeout, a failed connection or a 5xx). Even then, a bad signature or a malformed answer still fails closed. Think hard before choosing it. A guard that steps aside whenever it is down protects you only on good days.

Keep the hook fast. Do not call slow services inside it. Read anything you need, such as the day's starting equity, from memory or a local store.

To ask a person first, answer `hold`. Nothing is sent. The hold gets the id from `hold_id_if_held`, and your service or a person approves or rejects it later with a signed call to `POST /v1/pretrade-holds/{hold_id}/release`. An approved hold is sent once, with the original idempotency key. You can set how long a hold lives with `hold_ttl_seconds` in your answer. A hold that expires is never sent.

## Register and test it

Register the hook in the dashboard under **AI agents** > **Pre-trade hooks**, or with the API using a full API key:

```bash
curl -s -X POST https://api.tickwire.io/v1/pretrade-hooks \
  -H "Authorization: Bearer $TICKWIRE_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: guard-<ACCOUNT_ID>" -H "X-Reason: add risk guard" \
  -d '{"url": "https://guard.example.com/decide", "account_id": "<ACCOUNT_ID>",
       "applies_to": "agents", "fail_mode": "closed", "timeout_ms": 2000}'
```

Set exactly one of `account_id` or `agent_key_id`. There is one hook per account and one per agent key, and when both exist both are asked, the account's first. `applies_to: "all"` also sends your own API-key trades on that account through the hook. The answer contains `secret` once; store it as `HOOK_SECRET`.

Before you trust it, test it. FastAPI's test client lets you sign a request locally:

```python
from fastapi.testclient import TestClient
from hook import app, sign
import json, time

client = TestClient(app)
req = {"type": "pretrade.decision_request", "request_id": "dr_test", "account_id": "acc_test",
       "order": {"actionType": "ORDER_TYPE_BUY", "symbol": "EURUSD", "volume": 2.0},
       "account": {"equity": 10000.0}}
raw = json.dumps(req).encode()
ts = str(int(time.time()))
r = client.post("/decide", content=raw,
                headers={"X-Tickwire-Timestamp": ts, "X-Tickwire-Signature": sign(ts, raw)})
assert r.json()["decision"] == "deny"   # 2.0 lots is above MAX_LOTS
```

- Check that each rule denies what it should, and that a normal order is allowed.
- Send a request with a wrong signature and confirm the hook returns 401.
- Stop the hook and have the agent place a tiny order. It should get `hook_unavailable`, with nothing sent.
- Practise on a demo account before you risk money.

## Checklist

- Signature checked on the raw body with `hmac.compare_digest`
- Timestamps older than 5 minutes refused
- Every answer signed and echoing `request_id`
- Closes and cancels allowed, new exposure checked
- Exact broker symbol names in the list
- Hours in UTC, or converted from broker time on purpose
- Day-start equity stored outside the process
- Fails closed, and you tested that by stopping it
- Answers well inside `timeout_ms`

## Doing this with Tickwire

Tickwire calls your hook for every agent order on MT4 and MT5 accounts, after its own checks on key scope, read-only access and kill switches. Tickwire has no built-in size or loss limits and doesn't judge whether a trade is good; the rules are yours. The [AI agents page](https://tickwire.io/ai-agents/) shows the request and answer side by side, [risk controls for AI trading agents](https://tickwire.io/blog/ai-agent-risk-controls/) explains where a hook fits, and [build an AI trading agent](https://tickwire.io/docs/guides/ai-agent/) covers the agent side, including what `hook_denied` and `held` mean to it.
