---
title: "Send one agent Playground message"
method: POST
path: "/chat-sessions/messages"
tags: ["Chat sessions"]
---

# Send one agent Playground message

`POST /chat-sessions/messages`

Send one message to a project's MCP servers and get the model's reply plus the telemetry a participant in the conversation could not see: which tools ran, with what arguments, what each returned, per-call latency, and token usage.

**Spends model credits per call.** `idempotencyKey` is required and must be STABLE for the triggering intent — a fresh key per HTTP attempt deduplicates nothing, so a timeout-and-retry would run and bill the turn twice. With a stable key, a retry replays the completed turn.

Omit `sessionId` to start a session; pass the one this returns to continue it. Configuration (`modelId`, target, `systemPrompt`, `toolMode`) pins on the FIRST turn — a continuation that resends any of it is refused with `details.reason: "CONFIG_ON_CONTINUATION"`.

Only sessions created through this endpoint may be continued through it (`CONTINUATION_NOT_ALLOWED`): appending to a human's live Playground session would interleave two writers on one transcript.

`toolMode` defaults to `read_only`, which advertises only tools the server annotated `readOnlyHint: true`. That hint is server-asserted, so `read_only` is a policy this host applies, not a guarantee it can verify. `auto` advertises everything and **may cause real external side effects** through arbitrary third-party tools.

Pass `hostId` — or target an `environmentId`, which pins its own host — to run the turn AS a saved host. That is what selects the ENGINE: a host declaring an agent harness runs the real runtime, and one that cannot run here is refused BEFORE it spends (422, `details.reason: "HARNESS_UNAVAILABLE"`, with `details.kind` naming the rule that refused) rather than quietly downgraded to the emulated engine. Every response names what actually ran in `engine`. `hostId` is per-turn and cannot be pinned, so a continuation of a session that named ONLY a host must re-send it — omitting it is a 400 with `details.reason: "HOST_TARGET_REQUIRED"`, never a quiet fall back to the emulated engine.

## Request body

- SendChatMessageRequest
  - `idempotencyKey` string, required — STABLE identity for this turn's intent — reuse it when retrying. A fresh key per attempt deduplicates nothing and will bill the turn twice. Printable ASCII only.
  - `message` string, required — The message to send, as the user.
  - `projectId` string — Required to START a session; ignored when continuing one.
  - `sessionId` string — Continue this session. Omit to start a new one.
  - `modelId` string — Provider-prefixed model id, e.g. `anthropic/claude-sonnet-5`. Required on a first turn. A BARE id is rejected with `details.reason: "MODEL_AMBIGUOUS"` rather than guessed — an unprefixed id is indistinguishable from a local Ollama model, and guessing would spend on the wrong rail.
  - `hostId` string — The saved host (client) this turn executes AS — it decides which ENGINE runs. A host that declares an agent harness (Claude Code, Codex, Cursor CLI) runs the real runtime; without one the turn runs MCPJam's emulated engine. The server re-fetches that host's own runtime config, so `harness` and `computer` are NEVER read from the request body — a body carrying either is a 400. PER-TURN, not pinned to the session: re-send it on every turn, and read `engine` on the response to confirm what ran. A continuation of a session that named ONLY a host must re-send it — omitting it is refused with `details.reason: "HOST_TARGET_REQUIRED"` rather than run on the emulated engine. Alongside `environmentId` it is an assertion only — a host contradicting the environment's own is rejected with `details.reason: "HOST_TARGET_CONFLICT"`. Alone, the turn connects the host's own selected servers. A harness turn additionally requires `toolMode: "auto"` with no `allowedTools`/`maxToolCalls`: a harness builds its own tool set inside its sandbox, so the narrowing could not be applied, and the combination is refused rather than run with it silently dropped. It also cannot be combined with `serverIds`, because `hostId` cannot be pinned beside them and a later turn that omitted it would run the emulated engine on a session established on the harness (422, `details.kind: "surface-unpinnable-host"`) — target an `environmentId`, or send `hostId` alone and narrow per turn with `allowedServerIds`.
  - `environmentId` string — Target this environment's servers. Mutually exclusive with `serverIds`. First turn only.
  - `serverIds` string[] — Target these project servers. Mutually exclusive with `environmentId`. First turn only. Server CONFIGS are never accepted — only ids the project already holds.
  - `systemPrompt` string — First turn only.
  - `temperature` number — First turn only — pinned to the session and reused on every continuation.
  - `maxSteps` integer
  - `toolMode` 'read_only' | 'auto' — `read_only` advertises only tools annotated `readOnlyHint: true`. `auto` advertises everything and MAY CAUSE REAL EXTERNAL SIDE EFFECTS. First turn only.
  - `allowedServerIds` string[] — Narrow THIS TURN to a subset of the target's servers. An empty array narrows to none and is rejected — omit the field to use the whole target. Per-turn, not pinned.
  - `allowedTools` string[] — Advertise only these tool names, for THIS TURN. An empty array advertises no tools at all — the same request as `maxToolCalls: 0`. Per-turn, not pinned.
  - `maxToolCalls` integer — Cap the tool calls this turn may make, enforced at DISPATCH rather than by bounding steps (one step can emit several parallel calls). `0` advertises no tools at all.
  - `browser` SessionBrowserInput
    - `policy` BrowserToolPolicy
      - `mode` 'allow_all' | 'read_only' | 'allowlist', required
      - `originAllowlist` string[]
      - `toolAllowlist` string[]
    - `profileId` string

## Response `200`

The turn ran. `persisted.outcome` reports whether the transcript landed — a turn that ran but failed to persist still spent, so this is a 200 with an honest `persisted` block rather than an error.

- ChatTurn
  - `sessionId` string, nullable, required — The one public session id. Pass it back to continue, and to the trace/detail reads. NULL only when the turn ran but its transcript did not persist — which `persisted.outcome` reports, and which must not be read as "nothing happened": the turn already spent.
  - `turnId` string, required — Minted by the turn lease and used by the ingest dedupe, so it names the same turn in both.
  - `projectId` string, required — The project this turn ran in. On a continuation the caller never sent it — it comes off the session row — so this is the only place the response names the session's project.
  - `reply` string
  - `finishReason` string, nullable
  - `toolCalls` ChatTurnToolCall[]
    - `toolCallId` string, required
    - `toolName` string, required
    - `input` unknown, required
    - `status` 'ok' | 'error', required
    - `output` unknown
    - `errorMessage` string
    - `truncated` boolean — The payload was clipped. Always announced — a silently shortened result an agent believes is complete sends it debugging the wrong thing.
  - `trace` object — This turn's spans, inline.
    - `turnId` string
    - `spanCount` integer
    - `spans` object[]
  - `usage` TurnUsage
    - `inputTokens` integer
    - `outputTokens` integer
    - `totalTokens` integer
  - `model` object
    - `id` string
    - `provider` string
  - `toolMode` 'read_only' | 'auto'
  - `engine` string — WHICH ENGINE RAN: `emulated`, or `harness:<id>` such as `harness:claude-code`. Present on every turn. Read it rather than infer it from `model`. `hostId` is per-turn: a continuation of a host-only session is refused without it, and one that pinned its own `serverIds` runs the emulated engine — this is the field that says which.
  - `hostId` string — The saved host this turn executed as — the pointer the caller sent, or the host the targeted environment pins. Absent when the turn named no host.
  - `advertisedToolCount` integer — Tools the model could see this turn.
  - `excludedToolCount` integer — Tools the tool policy withheld.
  - `persisted` object, required
    - `outcome` string
    - `version` integer
  - `origin` 'api', required
  - `replay` boolean — Set when this idempotencyKey replayed an already-completed turn. Nothing was spent.
  - `message` string
  - `chatSessionId` string
  - `browser` object
    - `effectivePolicy` object — Permissions after intersecting the stored grant, current host ceiling, and read-only tool mode. Null is unrestricted; an empty list denies all.
      - `tools` string[], nullable, required
      - `origins` string[], nullable, required
    - `browserSessionId` string
    - `state` 'active' | 'sleeping' | 'closed'
    - `policy` BrowserToolPolicy
      - `mode` 'allow_all' | 'read_only' | 'allowlist', required
      - `originAllowlist` string[]
      - `toolAllowlist` string[]
    - `profileId` string
    - `bootId` string
    - `controlledBy` 'human' | 'null', nullable
    - `box` string, nullable
    - `lastActiveAt` number
    - `attached` boolean, required
    - `reason` string
    - `screenshots` BrowserScreenshot[]
      - `turnId` string
      - `toolCallId` string, required
      - `toolName` string
      - `stepIndex` integer, required
      - `status` 'ready' | 'not_captured' | 'unavailable'
      - `url` string, uri
      - `mediaType` 'image/png' | 'image/jpeg'
      - `bytes` integer
      - `ts` number
    - `notices` string[]
    - `handoff` object
      - `waited` boolean, required
      - `resumed` boolean, required

## Other responses

- `400` — Malformed body or parameters.
- `401` — Missing, invalid, revoked, or orphaned key (`UNAUTHORIZED`) — or the **target MCP server** needs an OAuth grant (`OAUTH_REQUIRED`), which is a property of the server, not your key.
- `403` — Key is valid but not allowed to do this.
- `404` — Unknown project, server, or resource.
- `409` — `details.reason` is one of `TURN_IN_PROGRESS` (another turn holds this session's lease; retry after `details.retryAfterMs`), `CONTINUATION_NOT_ALLOWED`, or `SESSION_VERSION_CONFLICT`.
- `422` — The target server doesn't support this MCP capability.
- `429` — Per-key rate limit exceeded (60 requests/minute sustained, bursts up to 10). Honor `Retry-After` and back off with jitter.
- `500` — Something failed on MCPJam's side.
- `502` — Could not connect to the target MCP server.
- `504` — The target MCP server connected but didn't respond in time.

## Changes

- **2026-09-10** `b4b7857c1379` — 3 info
  - added the new optional request property `browser`
  - added the optional property `browser` to the response with the `200` status
  - added the optional property `chatSessionId` to the response with the `200` status
- **2026-09-02** `908e54dd53d8` — 3 info
  - added the new optional request property `hostId`
  - added the optional property `engine` to the response with the `200` status
  - added the optional property `hostId` to the response with the `200` status
- **2026-08-26** `9b2992990271` — 1 info
  - added the required property `projectId` to the response with the `200` status
- **2026-08-24** `d8964f1ecc61` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/mcpjam/apis/mcpjam-api/changes/chat-sessions/messages/post.md)

---

[API](https://skmtc.dev/mcpjam/apis/mcpjam-api.md) · [All operations](https://skmtc.dev/mcpjam/apis/mcpjam-api/llms.txt) · [OpenAPI document](https://skmtc.dev/mcpjam/apis/mcpjam-api/revisions/0e05c5e4277f?raw)
