# Build an AI trading agent

> Give your own AI agent a scoped agent key, connect it to the Tickwire MCP server or the agent REST routes, and handle held, rejected and unknown outcomes safely.

Source: https://tickwire.io/docs/guides/ai-agent/

An AI agent is a program where a language model decides which tools to call. This guide shows
how to give one access to a MetaTrader 4 or 5 account through Tickwire, so that it can look,
and trade only if you allow it, with every order checked and recorded.

There are two ways in. Both use the same guarded path and the same agent key.

| Route | Best for | How the key is sent |
|---|---|---|
| The Tickwire MCP server | Agent frameworks that speak MCP (Claude Agent SDK, OpenAI Agents SDK, Claude Code, Cursor) | `Authorization: Bearer twag_…` |
| The agent REST routes, `/v1/agent/…` | Your own tool-calling loop, or any language | `X-Tickwire-Agent-Key: twag_…` |

AI apps such as Claude and ChatGPT don't need a key at all: they add the MCP address and sign in
with Tickwire. See [AI agents](https://tickwire.io/ai-agents/#connect) for those steps.

## 1. Create an agent key

Create the key in the dashboard under **AI agents > Keys**, or with the API using your own API
key:

```bash
curl -s -X POST https://api.tickwire.io/v1/agent-keys \
  -H "Authorization: Bearer $TICKWIRE_API_KEY" \
  -H "content-type: application/json" -H "X-Reason: research agent" \
  -d '{"name": "research-agent", "account_ids": ["<ACCOUNT_ID>"], "read_only": true}'
```

- The answer's `key` (`twag_…`) is shown **once**. Keep it in a secret store or an environment
  variable, never in source control.
- `account_ids` limits the key to those accounts. The agent can't see any others.
- `read_only: true` is the safe default. A read-only key that tries to trade gets
  `403 read_only_key` and nothing is sent. Change it later with `PATCH /v1/agent-keys/{id}` or in
  the dashboard.
- Agent keys can't call the direct terminal routes (they get `401 invalid_token`), so an agent
  can't go around the checks below. See [Authentication](https://tickwire.io/docs/authentication/).

Check what the key may do:

```bash
curl -s -H "X-Tickwire-Agent-Key: $AGENT_KEY" https://api.tickwire.io/v1/agent/me
```

## 2. What happens to every agent order

Every trade from an agent passes the same checks, in this order:

1. **Scope.** The account must be one the key may use.
2. **Read-only.** A read-only key stops here with `read_only_key`.
3. **Kill switches.** One per agent key and one per account. Engaged means
   `kill_switch_engaged`, and nothing is sent.
4. **Your pre-trade hook**, if you set one. Tickwire posts the order, who asked, why, and a fresh
   read of the account to your HTTPS endpoint, signed with HMAC-SHA256. Your hook answers
   `allow`, `deny` or `hold`. If it can't be reached or its answer can't be verified, the order is
   not sent. See [the hook](https://tickwire.io/ai-agents/#hook).
5. **Sent once.** The order reaches the terminal once per idempotency key, and the answer says
   what actually happened.

Tickwire has no built-in size or loss limits and doesn't judge whether a trade is a good idea.
Rules like a maximum volume, a daily loss limit or trading hours belong in your hook.

## 3. Read the outcome correctly

Every trading answer has an `outcome`. Teach your agent what each one means, in its instructions
and in your code:

| `outcome` | Meaning | What the agent should do |
|---|---|---|
| `applied` | The terminal confirmed it (`evidence` says how) | Carry on |
| `no_change` | Nothing needed doing, for example it was already closed | Carry on |
| `rejected` | Not executed. `sent: false` means it never left Tickwire | Read `error`; don't loop on it |
| `held` | Your hook is holding it for approval. Nothing is sent yet | Wait. Check later; never resend |
| `unknown` | It **may** have executed | Check before doing anything else |

After `unknown`, never retry with a new idempotency key. Ask what happened first:
`GET /v1/agent/requests/{idempotency_key}` (the MCP tool `get_trade_status`), then the positions
and orders. Repeating a call with the **same** key is always safe: you get the first answer back
with `replayed: true`, never a second order. More in [Idempotency](https://tickwire.io/docs/idempotency/).

`hook_denied`, `kill_switch_engaged` and `read_only_key` are the account owner's decisions.
The agent should report them, not try to work around them.

## 4. Connect an agent through MCP

The MCP server address is `https://api.tickwire.io/mcp`. Its tools include `list_accounts`,
`get_agent_permissions`, `get_account_health`, `get_account_info`, `get_positions`,
`get_orders`, `get_quote`, `get_symbol_spec`, `get_candles`, `get_history`, `place_order`,
`modify_position`, `close_position`, `modify_order`, `cancel_order`, `get_trade_status` and
`get_audit_log`. Every trading tool needs a `reason`, which goes into the audit log. The server
adds an idempotency key if the agent doesn't give one (`client_request_id`).

Both examples below give the model only the tools it needs. That is a good habit: an agent that
only reads should not even see `place_order`.

### Claude Agent SDK (Python)

```bash
pip install claude-agent-sdk
```

```python
import asyncio, os
from claude_agent_sdk import query, ClaudeAgentOptions

options = ClaudeAgentOptions(
    mcp_servers={
        "tickwire": {
            "type": "http",
            "url": "https://api.tickwire.io/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['AGENT_KEY']}"},
        }
    },
    allowed_tools=[
        "mcp__tickwire__list_accounts",
        "mcp__tickwire__get_account_health",
        "mcp__tickwire__get_positions",
        "mcp__tickwire__get_quote",
    ],
    system_prompt=(
        "You read MetaTrader accounts through Tickwire. Check account health first. "
        "Times are UTC unless a field says broker time."
    ),
)

async def main():
    async for message in query(prompt="Show the health and open positions of my first account.",
                               options=options):
        print(message)

asyncio.run(main())
```

### OpenAI Agents SDK (Python)

```bash
pip install openai-agents
```

```python
import asyncio, os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter

async def main():
    async with MCPServerStreamableHttp(
        name="tickwire",
        params={
            "url": "https://api.tickwire.io/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['AGENT_KEY']}"},
        },
        tool_filter=create_static_tool_filter(
            allowed_tool_names=["list_accounts", "get_account_health", "get_positions", "get_quote"]
        ),
    ) as tickwire:
        agent = Agent(
            name="Account reader",
            instructions="You read MetaTrader accounts through Tickwire. Check account health first.",
            mcp_servers=[tickwire],
        )
        result = await Runner.run(agent, "Show the health and open positions of my first account.")
        print(result.final_output)

asyncio.run(main())
```

Option names in agent frameworks change between versions. If one of these fails, check the
framework's own documentation for remote MCP servers with custom headers.

## 5. Or call the agent REST routes yourself

If you run your own tool-calling loop, map each tool to a plain HTTP call. The reads:

| Route | What it returns |
|---|---|
| `GET /v1/agent/accounts` | Accounts in the key's scope |
| `GET /v1/agent/accounts/{id}/health` | `connected`, `disconnected` or `unknown`, with `evidence` |
| `GET /v1/agent/accounts/{id}/account-information` | Balance, equity, margin, currency |
| `GET /v1/agent/accounts/{id}/positions` and `…/orders` | Open positions and pending orders |
| `GET /v1/agent/accounts/{id}/symbols/{symbol}/current-price` | Bid and ask |
| `GET /v1/agent/accounts/{id}/history-deals` | Deals, 24 hours by default, at most 31 days |

Trading is one route, `POST /v1/agent/accounts/{id}/trade`, and it needs an `Idempotency-Key`
and an `X-Reason` header. Here is a trade function that handles every outcome:

```python
import os, uuid, requests

API = "https://api.tickwire.io"
HEADERS = {"X-Tickwire-Agent-Key": os.environ["AGENT_KEY"]}

def trade(account_id: str, order: dict, reason: str, key: str | None = None) -> dict:
    """Send one order. Reuse `key` to repeat the same order safely."""
    key = key or str(uuid.uuid4())
    try:
        r = requests.post(f"{API}/v1/agent/accounts/{account_id}/trade", json=order, timeout=30,
                          headers={**HEADERS, "Idempotency-Key": key, "X-Reason": reason})
    except requests.RequestException:
        # No answer: the order may have gone. Ask, with the same key.
        return status(key)
    body = r.json()
    body["idempotency_key"] = key
    return body

def status(key: str) -> dict:
    r = requests.get(f"{API}/v1/agent/requests/{key}", headers=HEADERS, timeout=30)
    return r.json()

result = trade("<ACCOUNT_ID>",
               {"actionType": "ORDER_TYPE_BUY", "symbol": "EURUSD", "volume": 0.01},
               reason="example order from the guide")

match result.get("outcome"):
    case "applied" | "no_change":
        print("done", result.get("evidence"))
    case "held":
        print("waiting for approval, hold", result.get("hold_id"))
    case "unknown":
        print("may have executed: check", status(result["idempotency_key"]))
    case _:
        print("not executed:", result.get("error"), result.get("message"))
```

The order uses absolute prices only, in lots. `actionType` covers market, limit, stop and
stop-limit orders, `POSITION_MODIFY`, `POSITION_PARTIAL`, `POSITION_CLOSE_ID`, `ORDER_MODIFY`
and `ORDER_CANCEL`. MT4 has no stop-limit orders, and those answer `not_supported_on_mt4`. See
[Platforms](https://tickwire.io/docs/platforms/).

A `409 idempotency_in_flight` answer means an earlier request with the same key is still running.
Wait for `Retry-After` and repeat with the same key.

## 6. Stop an agent, and see what it did

Engage a kill switch to stop an agent at once. Nothing else it sends will reach the broker:

```bash
curl -s -X PUT https://api.tickwire.io/v1/agent-keys/<KEY_ID>/kill-switch \
  -H "Authorization: Bearer $TICKWIRE_API_KEY" \
  -H "content-type: application/json" -d '{"engaged": true, "reason": "stop the agent"}'
```

The per-account switch is `PUT /v1/agent-kill-switches/accounts/{account_id}`, and both are in
the dashboard under **AI agents**. Everything an agent did, with its reasons and outcomes, is in
`GET /v1/agent-audit` (yours, as the owner) and `GET /v1/agent/audit` (the agent's own log).

## Checklist

- One agent key per agent, scoped to the accounts it needs, read-only until you're sure.
- Only the tools it needs.
- Size, loss and hours rules in your [pre-trade hook](https://tickwire.io/ai-agents/#hook), not in the prompt.
- Every trade with a reason and an idempotency key.
- `unknown` means check, never resend with a new key.
- A kill switch you know how to reach.
- Practise against the [sandbox](https://tickwire.io/docs/sandbox/) first.

Further reading: [Which AI apps can place trades on MT4 and MT5?](https://tickwire.io/blog/ai-apps-that-can-trade/),
[Risk controls for AI trading agents](https://tickwire.io/blog/ai-agent-risk-controls/) and
[Safe prompts for an AI trading assistant](https://tickwire.io/blog/safe-prompts-ai-trading/).
