# Agents

> Chat with AI agents via REST. For SDK helpers (sendMessage, run, streaming), see the Agent SDK.

**URL:** https://inference.sh/docs/api/rest/agents
**Last updated:** 2026-10-12

---

Chat with AI agents via REST. For SDK helpers (`sendMessage`, `run`, streaming), see the [Agent SDK](/docs/api/agent/overview).

Most integrations use **`POST /agents/run`**: it creates a chat when needed, sends the user message, and returns assistant output. For a pure chat-first flow (create a chat, then send messages separately), use **`POST /chats`** followed by **`POST /chats/:id/messages`**, the same pattern the web app and React `AgentChatProvider` use for new conversations (`client.agent().sendMessage()` still uses `POST /agents/run`). Use the tool endpoints below for client tools, widgets, and human-in-the-loop (HIL) approval.

---

## Run agent (recommended)

`POST /agents/run`

Send a message to a template or ad-hoc agent. Creates a new chat when `chat_id` is omitted. For a new template-backed chat in the agent's **home workspace**, the resolved agent version's [`permissions`](#agent-version-permissions) (for example `allow_all_tools`) are copied into the chat once at creation.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `chat_id` | string | No | Existing chat ID (omit to start a new chat) |
| `agent` | string | No | Template agent ref (`namespace/name` or `namespace/name@version`) |
| `agent_id` | string | No | Stored agent ID |
| `agent_version_id` | string | No | Version of `agent_id` to run (full or short ID). Without it the agent's current version runs |
| `agent_config` | object | No | Ad-hoc agent config (tools, system prompt, core app) |
| `agent_name` | string | No | Display name for ad-hoc chats |
| `context` | object | No | Per-chat [context variables](/docs/api/agent/template#per-chat-context) (string values). Stored on a new chat, or on an existing chat that has none |
| `input` | object | Yes | The message (see below) |
| `stream` | boolean | No | If `true`, response is SSE instead of JSON |

Name the agent exactly one way: `agent`, `agent_id` (with optional `agent_version_id`), or `agent_config`. Or name none and send only `chat_id` to post into a chat that already has its agent. The API returns `400` `validation_error` when the agent is named more than one way, when `agent_version_id` comes without `agent_id`, or when neither an agent nor `chat_id` is given. An `agent_version_id` that does not exist or that you cannot read returns `404`, and nothing is written.

A body that still carries `channel_context` is accepted and the field is ignored. See [Channel context](#channel-context). `trigger_id` and `tool_invocation_id` in a client's body are ignored too: the server sets them for messages that triggers and sub-agent calls send.

**`input` fields (`MessageInput`):**

| Field | Type | Description |
|-------|------|-------------|
| `text` | string | User message text |
| `attachments` | array | File refs from the [Files API](/docs/api/rest/files) (`POST /files`): `uri`, `filename`, `content_type`, optional `id` and `size`. Images reach the model as image input |
| `images` | array | Deprecated. Image URIs, read only when `attachments` is empty |
| `files` | array | Deprecated. File URIs, read only when `attachments` is empty |

Other fields older clients send in `input` (`role`, `context`, `system_prompt`, `context_size`, `reasoning`, `tools`, settings) are accepted and ignored. Settings, the system prompt, tools and history come from the agent and the chat.

**Template agent (new chat):**

```json
{
  "agent": "myteam/support-agent@latest",
  "context": {
    "project_id": "proj_456"
  },
  "input": {
    "text": "Summarize open tickets"
  }
}
```

**Continue chat:**

```json
{
  "chat_id": "chat_abc123",
  "input": {
    "text": "What about priority ones?"
  }
}
```

### Response

The response body is the DTO under `data`:

```json
{
  "user_message": { "id": "msg_...", "chat_id": "chat_abc123", "role": "user", "content": [...] },
  "assistant_message": { "id": "msg_...", "chat_id": "chat_abc123", "role": "assistant", "content": [...], "tool_invocations": [...] }
}
```

See [REST overview — response format](/docs/api/rest/overview#response-format).

Set `stream: true` to receive message updates over SSE. See [Stream chat updates](/docs/api/rest/streaming#stream-chat-updates). Streaming applies when a new agent run starts — if the chat already has an active run, the message is [auto-queued](#queuing-while-a-run-is-active) instead (no SSE stream).

The turn runs on the server independently of the request. Closing a streaming `POST /agents/run` connection does not stop the turn: reconnect with [Stream chat updates](/docs/api/rest/streaming#stream-chat-updates) or poll [List chat messages](#list-chat-messages) to pick it up. Use [Stop chat](#stop-chat) to end a turn.

### Queuing while a run is active

When `chat_id` points to a chat with a **non-terminal** agent run (`status: busy`), `POST /agents/run` and `POST /agents/messages` do not start a new run. The user message is queued for the agent's next turn, the same behavior as [Send chat message](#send-chat-message).

Each chat has at most one active agent run. Rapid follow-up messages are queued rather than starting a parallel run or stopping the in-progress response — use [Stop chat](#stop-chat) to cancel explicitly.

**Remote harness exception.** When the active run uses a profile with `capabilities.steer: true` (for example Claude Code or Codex, as reported at registration), mid-turn messages are forwarded into the running harness turn at once. The user message is `ready` immediately and is tied to the active run — not held until the next turn. Profiles with `steer: false` (typical ACP harnesses) keep the queued behavior below. See [Remote harness runs — Mid-turn messages](#remote-harness-runs).

The response returns immediately with only `user_message` (`status: "queued"` for most runs, or `"ready"` when a steerable remote harness accepted the message mid-turn). `assistant_message` is omitted. Poll or stream the chat to see when the agent picks up the message and generates a reply.

```json
{
  "user_message": {
    "id": "msg_...",
    "chat_id": "chat_abc123",
    "role": "user",
    "status": "queued",
    "content": [{ "type": "text", "text": "Focus on P0 tickets only" }]
  }
}
```

Use `POST /chats/:id/messages` when you only need to send a message without other run parameters. See [Chat message status](#chat-message-status).

---

## Send message (alternate)

`POST /agents/messages` or `POST /agents/message`

Both are the same endpoint as [Run agent](#run-agent-recommended): same request body (including `stream`) and same response.

```json
{
  "agent_id": "agent_abc123",
  "agent_version_id": "ver_xyz789",
  "input": { "text": "Hello" }
}
```

---

## Create chat

`POST /chats`

Create a chat, optionally assigning a template agent before the first message. Pair with [Send chat message](#send-chat-message) for a two-step new conversation: create the chat, then post the first user message.

Use **`POST /agents/run`** when you want one call that creates the chat, sends the message, and returns assistant output — recommended for scripts, ad-hoc `agent_config`, per-chat `context`, attachments, and [A2A](https://a2a-protocol.org/latest/specification/) integrations. Use the chat-first flow when you need a chat ID before sending (for example to open a [stream](/docs/api/rest/streaming#stream-chat-updates) or navigate in a SPA).

Requires `conversations:write` scope.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `agent` | string | No | Template agent ref (`namespace/name` or `namespace/name@version`) |
| `name` | string | No | Display name for the chat |
| `context` | object | No | Per-chat [context variables](/docs/api/agent/template#per-chat-context) (string values) |

When `agent` is provided, the API resolves the template and pins `agent_id` and `agent_version_id` on the chat (same version pinning as [Run agent](#run-agent-recommended)). When the chat is in the agent's **home workspace**, the pinned version's [`permissions`](#agent-version-permissions) (for example `allow_all_tools`) are **copied once** into this chat's `agent_data` when the chat is created; chats another workspace starts with a published agent do not copy those defaults. The chat owns copied values from then on (see [Chat settings](#chat-settings)). Omit `agent` to create an empty chat and assign an agent later with [Set agent](#set-agent).

### Response

Returns the chat DTO (same shape as [Get chat](#get-chat)).

### Errors

| HTTP status | Code | When |
|-------------|------|------|
| 400 | `invalid_request` | Invalid request body |
| 404 | `not_found` | Agent ref not found |

### Example — two-step new conversation

```bash
# 1. Create chat with agent
curl -X POST https://api.inference.sh/chats \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"agent": "myteam/support-agent@latest", "name": "Support"}'

# 2. Send first message (starts the agent run)
curl -X POST https://api.inference.sh/chats/chat_abc123/messages \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"message": "Summarize open tickets"}'
```

**JavaScript SDK:** `AgentChatProvider` / `useAgentActions().sendMessage()` on a template agent with no bound chat ID calls `POST /chats`, then `POST /chats/{id}/messages`; call `reset()` before the next message to start a new chat. `client.agent().sendMessage()` still uses `POST /agents/run`.

---

## List chats

`GET /chats` or `POST /chats/list`

Cursor-paginated list of chats. `POST /chats/list` accepts a JSON body with `limit`, `cursor`, `direction`, and `filters` (for example `status` in `busy`, `awaiting_input`).

Each item includes the default chat projection: `id`, `name`, `status`, `active_run_id`, and `channel_context` when the chat originated from an external messaging channel. Use [Get chat](#get-chat) for the full metadata shape (`output`, `context`, `active_run`, …).

---

## Get chat

`GET /chats/:id`

Returns chat metadata — status, structured output, per-chat `context`, optional `channel_context`, and active run. Does **not** include message history or tool invocations.

### Response

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Chat ID |
| `name` | string | Display name in the chat list and settings |
| `short_id` | string | Short id used in public share links (`https://1nf.sh/chats/{short_id}`) |
| `visibility` | string | Who can open the chat: `private` (only you), `team` (your workspace), or `public` (anyone with the link) |
| `status` | string | `busy`, `idle`, `awaiting_input`, or `completed` |
| `agent_data` | object | Per-chat agent state: `memory` (key–value notes from the agent's [memory tools](/docs/api/agent/internal-tools#memory_set)), `allow_all_tools` (when `true`, every tool runs without asking), and `disable_hooks` (when `true`, [lifecycle hooks](/docs/api/agent/lifecycle-hooks) do not run in this chat). Per-tool approvals are stored as [chat rules](#chat-permissions-rules), not in `agent_data` |
| `output` | object | Structured result from the agent's `finish` tool (when set) |
| `context` | object | Per-chat context variables, used as `{{context.NAME}}` in tool calls and URL templates (see [Per-chat context](/docs/api/agent/template#per-chat-context)) |
| `channel_context` | object | Present when the chat came through an external messaging channel (Slack thread, Telegram chat, …): `channel_type` plus opaque `channel_metadata` used to route replies back. Omitted for chats started in the app or SDK. See [Channel context](#channel-context). |
| `work_dir` | string | When set, the folder a remote harness works in for this chat |
| `harness_session_id` | string | When set, the harness's own session id on the remote (for example to resume with `claude --resume`) |
| `active_run` | object | Current agent run, when one is in progress |
| `work_dir` | string | When a remote harness drives this chat, the working directory on that machine (set at launch). Harness sessions are folder-bound; each chat can use a different folder with the same harness agent. |
| `harness_session_id` | string \| null | When a remote harness profile drives this chat, the harness vendor's session id (for example to resume on that machine). Omitted when the platform loop runs the agent. |
| `work_dir` | string | When set, the folder on the remote where the harness works for this chat (from launch or assignment). Omitted when empty. |
| `forked_from_message_id` | string \| null | Message id this chat was branched from. Not the same as `parent_id` on sub-agent chats. |

`context` and `channel_context` are unrelated: `context` holds the context variables you pass when starting a chat; `channel_context` is set by the platform when a chat is created from a [messaging channel webhook](#messaging-channel-webhooks).

```json
{
  "id": "chat_abc123",
  "status": "awaiting_input",
  "output": null,
  "context": { "project_id": "proj_123" },
  "channel_context": {
    "channel_type": "slack",
    "channel_metadata": { "channel": "C01234567", "thread_ts": "1234567890.123456" }
  },
  "active_run": {
    "id": "run_abc123",
    "state": "awaiting_input"
  }
}
```

`channel_context` is omitted on chats that did not originate from an external channel. `channel_metadata` shape is provider-specific and opaque to callers — treat it as pass-through data for the platform's outbound delivery.

`output` is populated when the agent calls the internal `finish` tool. See [Structured Output](/docs/api/agent/structured-output).

For message history and tool invocations, use [List chat messages](#list-chat-messages). For live updates, use [Stream chat updates](/docs/api/rest/streaming#stream-chat-updates) or `GET /chats/:id/status` for status-only polling.

Change `name`, `visibility`, per-chat tool approval defaults, or whether lifecycle hooks run with [Chat settings](#chat-settings). In the workspace, open **chat settings** from the gear icon in the chat header; see [Chat settings](/docs/agents/chatting#chat-settings). When a chat is created in the agent's home workspace, `allow_all_tools` is copied from the agent version's `permissions.allow_all_tools` when set; after that, only chat settings change it for that conversation.

### Remote placement on chats and runs

When an agent is bound to a [remote harness profile](/docs/extend/remote), responses include where thinking happens and which harness session backs the chat:

| Field | On | Meaning |
|-------|-----|---------|
| `harness` | Agent | What drives the agent: `inference` (platform loop, default for deployed templates) or an [agentprotocol](https://github.com/inference-sh/agentprotocol) registry id (`claude`, `codex`, …) for a harness on a remote. The UI uses this to decide what is editable; the registry describes display name, vendor, and capabilities. Harness agents use the harness's own instructions, tools, and versions — not `AgentVersionDTO` config. |
| `profile_id` | Agent, `active_run` | Harness profile that thinks for the agent or run. Omitted or null when the platform loop runs the agent. |
| `remote_id` | Agent, `active_run` | Remote machine id. Terminal and remote internal tools use this host by default. |
| `work_dir` | Chat | Folder on the remote for this harness session (from [Launch harness chat](/docs/api/rest/remotes#launch-harness-chat) or the workspace). |
| `harness_session_id` | Chat | Vendor session id on the remote, stable for the chat's lifetime. |
| `forked_from_message_id` | Chat | Branch point when this chat was forked from another conversation. |

On **`AgentDTO`**, `harness`, `profile_id`, and `remote_id` are deployment fields on the agent record (not on `AgentVersionDTO` config). Changing them affects new runs only. When `harness` is not `inference`, the agent is backed by an external harness — platform instructions, tools, and version history are not used for execution. Harness agents are created on first [harness launch](/docs/api/rest/remotes#launch-harness-chat) per profile; you cannot add new versions to them.

On **`AgentRunDTO`**, `profile_id` and `remote_id` are a **snapshot** taken when the run is created. Updating the agent later does not rewrite past runs; the turn driver is chosen from the run record.

### Remote harness runs

When `active_run.profile_id` is set, the API drives a harness session on that profile's remote instead of calling a hosted model in the platform loop. The chat wire format, streaming endpoints, approval interrupts, and queue behavior match a normal agent run — only execution moves to your machine.

| Phase | Behavior |
|-------|----------|
| **Start** | Opens or resumes the chat's harness session on the remote in `work_dir` (`harness_session_id` when resuming), then sends the user prompt. A new session in a chat that already has messages receives prior conversation text first so the harness continues rather than starts over. |
| **Streaming** | Harness output becomes assistant messages and broadcasts on the chat stream. Tool calls the harness makes on the host appear as tool invocations and tool messages with `type: harness` — they are recorded for the transcript and are **not** executed again on inference.sh. |
| **Approvals** | Harness permission requests surface as the standard approval interrupt (`input_required`). Allow or deny through the same REST/SDK paths as platform tools; the decision is forwarded to the harness once. If the harness **withdraws** a request before you answer (for example you interrupted the turn, or the harness moved on), it sends `approval.resolved` while the invocation is still `awaiting_approval`. The API cancels the tool call, resolves the interrupt, and the run is no longer blocked. Your own allow/deny that arrives after the invocation has already left `awaiting_approval` is ignored — the harness already handled it. |
| **Attachments** | Images and files on the message go with the prompt. The daemon downloads them to the machine and the harness gets images as images where it supports them (`capabilities.images`), other files by local path. A remote whose daemon predates belt v1.21.0 (no `capabilities.files`) gets the files' URLs in the prompt text. See [Remotes guide, Attachments](/docs/extend/remote#attachments). |
| **Mid-turn messages** | Depends on the profile's `capabilities.steer` from [registration](/docs/extend/remote#harness-discovery). When `steer` is **true**, follow-up user messages during a working turn are sent to the harness immediately and appear as `ready` on the active run. When `steer` is **false**, messages are **queued** for the next turn (same as [queuing while a run is active](#queuing-while-a-run-is-active)). |
| **Stop** | [Stop chat](#stop-chat) sends an interrupt to the harness turn in flight and keeps the session for later messages. |
| **End** | When the harness finishes the turn, the run completes and queued messages drain into the next turn. Session errors fail the run. |

**Chat rules on harness runs.** The chat's [permission rules](#chat-permissions-rules) (including rules added with [Always allow tool](#always-allow-tool)) apply to harness permission prompts the same way as to platform loop tools. If the harness asks to run a tool already covered by a chat **allow** rule for that harness tool, the run does **not** enter `input_required`: the invocation is recorded as in progress and the API answers the harness with `remote_session_resolve` (`decision: allow`, `scope: session`) so it can stop re-prompting for that tool name during the current harness session. Other tools still park for approval. When `agent_data.allow_all_tools` is `true` (see [Chat settings](#chat-settings)), every harness permission prompt is auto-approved the same way as platform tool approvals — turn it off again from chat settings when you want prompts back.

When you resolve a parked harness request, the API forwards your decision on the remote WebSocket:

| Action | `remote_session_resolve.scope` |
|--------|--------------------------------|
| `POST /tools/:id/invoke` (allow once) | `once` |
| `POST /tools/:id/reject` | `once` |
| `POST /chats/:chatId/tools/:toolInvocationId/always-allow` | `session` |

See [Remotes API — `remote_session_resolve`](/docs/api/rest/remotes#websocket-protocol).

**Start failures** (the run does not begin; error text is returned to the client):

| Condition | Typical message |
|-----------|-----------------|
| Remote not connected | `the remote this agent runs on is not connected` |
| Daemon without `--agents` | `this remote does not run agent sessions; start it with belt remote start --agents` |
| Profile missing, logged out, or not drivable | `the harness this agent runs on is not available on its remote` |

**Remote disconnect.** When a remote goes `disconnected` or `stopped`, every non-terminal harness run on that machine fails with `the remote disconnected`. Those turns are not retried automatically — the harness may already have changed files locally. Exec runs on the same remote fail the same way (`remote disconnected before the command finished`).

Setup: [Remotes guide — Agent harness sessions](/docs/extend/remote#agent-harness-sessions), [Profile-backed agent chats](/docs/extend/remote#profile-backed-agent-chats), and [Launch harness chat](/docs/api/rest/remotes#launch-harness-chat).

---

## List chat messages

`GET /chats/:id/messages` or `POST /chats/:id/messages/list`

Returns cursor-paginated messages for a chat. Requires the `conversations:read` scope (same as [Get chat](#get-chat)). The chat must exist and belong to your workspace.

Returns every message in the chat. Access is verified when loading the chat ID. Message rows are not filtered by creator or workspace.

Default sort is `order` descending (newest first) — load older pages as users scroll up. `POST /chats/:id/messages/list` accepts the same fields in a JSON body (`cursor`, `limit`, `direction`, `sort`, `filters`).

| Query / body field | Type | Description |
|--------------------|------|-------------|
| `limit` | integer | Page size (default 50). Pass `-1` to return up to 10,000 messages in one response — see [Cursor pagination](/docs/api/rest/overview#cursor-pagination). |
| `cursor` | string | Opaque cursor from `next_cursor` (ignored when `limit=-1`) |
| `direction` | string | `forward` or `backward` (ignored when `limit=-1`) |
| `sort` | array | Sort orders (default `[{ "field": "order", "dir": "desc" }]`) |
| `filters` | array | Filter expressions (same pattern as other list endpoints) |

Uses the same cursor pagination shape as [List skills](/docs/api/rest/skills#list-skills). Each `items` entry is a `ChatMessageDTO` with `role`, `status`, `content`, `order`, and optional `tool_invocations`. Transcripts can include `role: "event"` rows when [lifecycle hooks](/docs/api/agent/lifecycle-hooks#hook-runs-in-the-transcript) run — see [Chat message roles](#chat-message-roles).

**Message `order`:** Integer sequence within a chat (unique per `chat_id`, allocated by the server on insert). Sort transcripts by `order`, not `created_at` — messages written in the same turn can share a timestamp. While `status` is `queued`, `order` is provisional; when the message becomes `ready` on the agent's next turn, the server may assign a new `order` so it follows any tool results that completed while it was queued. See [Chat message status](#chat-message-status).

**Example (newest page):**

```bash
curl "https://api.inference.sh/chats/chat_abc123/messages?limit=50" \
  -H "Authorization: Bearer inf_your_key"
```

**Example response:**

```json
{
  "items": [
    {
      "id": "msg_xyz789",
      "chat_id": "chat_abc123",
      "role": "assistant",
      "status": "ready",
      "order": 4,
      "content": [{ "type": "text", "text": "Here is the summary." }],
      "tool_invocations": []
    }
  ],
  "next_cursor": "msg_abc456",
  "has_next": true,
  "has_previous": false,
  "items_per_page": 50,
  "total_items": 12
}
```

Pass `cursor` from `next_cursor` to fetch older messages.

**Example (full transcript — no pagination):**

```bash
curl "https://api.inference.sh/chats/chat_abc123/messages?limit=-1" \
  -H "Authorization: Bearer inf_your_key"
```

When `limit=-1`, `has_next` is `false` and up to 10,000 matching messages are returned in `items` (sorted and filtered as requested). Prefer cursor pagination for UI scroll-back; use `limit=-1` for exports or debugging on chats with fewer than 10,000 messages.

**JavaScript SDK (`@inferencesh/sdk` agent module, v0.6.40+):** `AgentChatProvider` loads one page of history using the server default `limit` (50) when `GET /chats/:id` omits `chat_messages`. **v0.6.41+:** `useAgentActions().loadOlderMessages()` fetches the next page via `cursor` and prepends deduplicated messages to `useAgentChat().messages`; `hasOlderMessages` is `true` when another page exists. For custom UIs outside `AgentChatProvider`, call this endpoint directly with `cursor` or `limit=-1`.

For live updates while the agent runs, use [Stream chat updates](/docs/api/rest/streaming#stream-chat-updates) instead of polling this endpoint.

---

## Get chat trace

`GET /chats/:id/trace`

Returns the execution graph for an agent chat: nodes (assistant turns, tool invocations, sync barriers, and long-running work such as app tasks and [`remote_exec`](/docs/api/agent/internal-tools#remote-tools) exec runs), edges (dependencies), and step counts by status. Requires the `conversations:read` scope (same as other chat endpoints).

Long-running tools add **execution** nodes linked to their backing resource (for example `resource_type` `task` for app tools, `exec_run` for remote commands). The tool invocation node stays in progress until that work finishes.

Use this to debug agent runs programmatically: see which tools ran, how long each step took, and where failures occurred. In the workspace, open **chat settings** → **agent** → **Show trace** for the same execution graph ([Chatting guide](/docs/agents/chatting#agent)).

### Response

| Field | Type | Description |
|-------|------|-------------|
| `graph_id` | string | Graph identifier (same as the chat ID) |
| `nodes` | array | Execution steps (`type`, `label`, `status`, `resource_id`, `started_at`, `completed_at`, `duration_ms`, …) |
| `edges` | array | Dependencies between nodes (`from_node`, `to_node`, `type`) |
| `total_steps` | number | Total nodes in the graph |
| `completed_steps` | number | Steps that finished successfully |
| `running_steps` | number | Steps still in progress |
| `failed_steps` | number | Steps that failed |

**Example:**

```bash
curl https://api.inference.sh/chats/chat_abc123/trace \
  -H "Authorization: Bearer inf_your_key"
```

JavaScript SDK: `client.chats.getTrace('chat_abc123')`.

→ [Observability](/docs/runtime/observability) · [Stream chat updates](/docs/api/rest/streaming#stream-chat-updates)

---

## Chat status values

| Status | Meaning |
|--------|---------|
| `busy` | Agent is working — LLM generation, or automated tool execution (apps, sub-agents, hooks, client tools, MCP) |
| `idle` | Agent finished; chat can accept new messages |
| `awaiting_input` | A human must act on this chat: HIL tool approval or an interactive widget |
| `completed` | Terminal state (archived chat) |

Client tools and hooks keep the chat in `busy` while they execute. Use `chat.status === "awaiting_input"` when you need a human on the conversation; use tool invocation status for per-tool progress.

---

## Chat message roles

Each message has a `role` that controls how the platform stores it and whether it is sent to the LLM on later turns.

| Role | Sent to LLM | Typical use |
|------|-------------|-------------|
| `user` | Yes | End-user input |
| `assistant` | Yes | Model output and tool calls |
| `tool` | Yes | Tool results |
| `system` | Yes | System instructions |
| `event` | No | System info for the transcript (for example a [lifecycle hook handler run](/docs/api/agent/lifecycle-hooks#hook-runs-in-the-transcript)) |
| `injection` | Folded in | Internal context; folded into the user turn when context is built, not sent as its own message |
| `compaction` | Replaced | Compaction marker; replaced by its summary when context is built |

Messages with `injection`, `compaction` or `event` roles still appear in [List chat messages](#list-chat-messages) and on the [chat stream](/docs/api/rest/streaming#stream-chat-updates) with `status: "ready"`. Custom UIs can render them; integrations that rebuild LLM context from the transcript must skip `event` rows and treat `injection` and `compaction` as described above. Clients can only create `user` messages; every other role is written by the platform.

**Event content:** Event messages use a content block with `type: "event"`. The block includes short `text` plus structured `event` data (today `event.type` is `"hook"` with a `hook` object). See [Lifecycle hooks — Hook runs in the transcript](/docs/api/agent/lifecycle-hooks#hook-runs-in-the-transcript).

**SDK types:** `ChatMessageRoleEvent`, `ChatEvent`, and `ChatHookEvent` in `@inferencesh/sdk` / generated Python types.

---

## Chat message status

Each entry in `chat_messages` has its own `status`, separate from the chat-level `status` above.

| Status | Meaning |
|--------|---------|
| `pending` | Assistant message still being generated (LLM stream in progress) |
| `queued` | User message queued while the agent is still running — via [Send chat message](#send-chat-message) or [auto-queue on run/message](#queuing-while-a-run-is-active) — waiting for the next turn |
| `ready` | Complete — included in LLM context on subsequent turns |
| `failed` | Generation failed, or the run could not start |
| `cancelled` | Cancelled — via [Cancel message](#cancel-message) or when the chat run is stopped |

Messages from `POST /agents/run` or `POST /agents/messages` start as `status: ready` when no run is active. When a run is already active, they are auto-queued with `status: queued` instead. When the run cannot start (for example its agent version is gone or you cannot read it), the message is marked `failed` and the request returns the error. Messages sent via `POST /chats/:id/messages` (or auto-queue) transition to `ready` when the agent begins its next turn, before context is built.

Poll [List chat messages](#list-chat-messages) or [stream chat updates](/docs/api/rest/streaming#stream-chat-updates) to detect `queued` messages — the stream emits `chat_messages` events as status transitions from `queued` to `ready`, often including an updated `order` in the same partial update. To withdraw a queued message before the agent consumes it, use [Cancel message](#cancel-message).

While a message is still `queued`, sorting the transcript by `order` alone can place it between an in-flight tool call and its result; that position is corrected when the message is readied.

**SDK types:** Import `ChatMessageStatusQueued` from `@inferencesh/sdk` (JavaScript) or `ChatMessageStatus.QUEUED` from `inferencesh.types` (Python) when comparing message `status` values. **JavaScript SDK (v0.6.36+):** `client.chats.cancelMessage(messageId)` and `useAgentActions().cancelMessage(messageId)` with `AgentChatProvider` cancel queued messages. Python SDK does not expose `cancelMessage` yet.

---

## Delete chat

`DELETE /chats/:id`

Permanently deletes a chat, its messages, and tool history. Requires `conversations:write` scope. The workspace **delete chat** action in [chat settings](/docs/agents/chatting#chat-settings) calls this endpoint.

**JavaScript SDK:** `client.chats.delete('chat_abc123')`.

---

## Stop chat

`POST /chats/:id/stop`

Stop the current agent run and cancel pending tool invocations.

**JavaScript SDK:** `client.chats.stop('chat_abc123')` — or `agent.stopChat()` when using `client.agent()`.

---

## Turn budget

Each chat has a turn budget that caps one uninterrupted stretch of agent work. The runtime counts the messages added since the last user message, ordered by message `order`. When the count reaches 500, the run ends without dispatching further tool calls.

| Behavior | Detail |
|----------|--------|
| What counts | Assistant and tool messages since the last user message |
| Limit | 500 consecutive non-user messages per chat |
| On exceed | Pending tool calls are dropped. The run ends the same way as when the model stops calling tools |
| User message | A new user message resets the counter for that chat |
| Scope | Per chat. Budget does not accumulate across user turns |

If the model's last response has no text, the runtime sets it to a message saying that the task was not finished and asking the user how to continue. Sub-agent and [agent tool](/docs/api/agent/internal-tools#agent-tool) chats are separate chats with their own budgets.

The budget stops tool-calling loops that never converge from running and consuming credits indefinitely. Use [Stop chat](#stop-chat) to end a run earlier.

---

## Context auto-compaction

Long chats can exceed the agent core app's **context size** (the model window configured on the agent version). Before each LLM turn, the platform estimates how many tokens the next prompt would send — system prompt, tool definitions, any prior compaction summary, and live transcript messages (including reasoning blocks, tool calls, tool results, and file or image references). When that estimate passes **80%** of the context size, the runtime compacts the oldest **live** turns so the newest **25%** of live content stays in full.

| Behavior | Detail |
|----------|--------|
| Trigger | Estimated prompt tokens &gt; 80% of the agent's context size |
| What stays | The newest live messages that fit in ~25% of the context budget |
| What is summarized | Older live turns, plus the text of any earlier compaction marker when compacting again |
| Cut boundary | Never splits a tool call from its tool result — both stay or both retire |
| When it runs | At the start of a turn, after queued user messages are readied and before the model call |
| Agent event | `context.compacted` on the agent run event stream (`before_tokens`, `after_tokens` in the payload) |

Compaction inserts a chat message with `role: "compaction"` and an extractive summary (user requests and assistant decisions; large tool outputs are dropped). When the agent version has a **core app**, the platform may call that app with a structured summarization prompt instead; on failure it falls back to extractive text. The marker's `compaction_meta` records `compacted_up_to_order`, `compacted_count`, `estimated_tokens`, and `method` (`extractive` or `llm`).

[List chat messages](#list-chat-messages) returns compaction markers in transcript order. **BuildContext** replaces each marker with its summary text when calling the model — markers are bookkeeping, not an extra user turn.

[Lifecycle hooks](/docs/api/agent/lifecycle-hooks): `agent.pre_compact` runs immediately before compaction; a synchronous webhook that returns `deny` or `stop` skips compaction for that turn. `agent.post_compact` fires after a successful compaction. Per-chat [`disable_hooks`](#chat-settings) turns both off for that chat.

→ [Long conversations in the workspace](/docs/agents/chatting#long-conversations-and-context)

---

## Set agent

`POST /chats/:id/agent`

Change which template agent handles an existing chat. Message history and per-chat [context](#get-chat) are preserved; the **next run** uses the new agent's tools and system prompt.

Agent switching is explicit — call this endpoint to change agents. Sending a message via `POST /agents/run`, `POST /agents/message`, or `POST /chats/:id/messages` does **not** change which agent owns the chat, and there is no per-message agent binding.

Requires `conversations:write` scope. In the JavaScript SDK this is `useAgentActions().switchAgent(ref)`. Only agents the platform loop runs can take over a chat; a chat on a remote harness keeps its agent (409).

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `agent` | string | Yes | Template agent ref (`namespace/name` or `namespace/name@version`) |

### Response

Returns what the switch changed, not the chat:

| Field | Type | Description |
|-------|------|-------------|
| `chat_id` | string | The chat |
| `agent_id` | string | The agent the chat now runs on |
| `agent` | object | That agent |
| `agent_version_id` | string | The agent version the next run uses |
| `agent_version` | object | That version |

Merge it into the chat you hold. Messages and the rest of the chat are unchanged.

### Errors

| HTTP status | Code | When |
|-------------|------|------|
| 400 | `invalid_request` | Missing or empty `agent` |
| 404 | `not_found` | Chat or agent not found |
| 409 | `conflict` | The chat or the agent runs on a remote harness, or the chat is a review branch |

### Example

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/agent \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"agent": "myteam/escalation-agent@latest"}'
```

---

## Chat settings

`POST /chats/:id/settings`

Change a chat's settings. A field left out is left as it is. Requires `conversations:write` scope. In the JavaScript SDK this is `useAgentActions().updateChatSettings(settings)`.

### Request

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Rename the chat. Cannot be empty. |
| `visibility` | string | `private`, `team` or `public` |
| `allow_all_tools` | boolean | Run every tool call without asking. Switching it on also approves the calls already waiting. |
| `disable_hooks` | boolean | Stop the agent's [lifecycle hooks](/docs/api/agent/lifecycle-hooks#switching-hooks-off-for-a-chat) firing in this chat |
| `forget_memory` | string[] | Remove these keys from the chat's memory. A key that is not there is ignored. |

### Response

Returns the chat's settings after the change, not the chat:

| Field | Type | Description |
|-------|------|-------------|
| `chat_id` | string | The chat |
| `name` | string | Chat name |
| `visibility` | string | `private`, `team` or `public` |
| `allow_all_tools` | boolean | `agent_data.allow_all_tools` on the chat |
| `disable_hooks` | boolean | `agent_data.disable_hooks` on the chat |
| `memory` | object | `agent_data.memory` on the chat, after `forget_memory` |

Merge it into the chat you hold. Calls approved by switching `allow_all_tools` on arrive on the [chat stream](/docs/api/rest/streaming#stream-chat-updates).

### Example

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/settings \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name": "release notes", "forget_memory": ["draft"]}'
```

```json
{
  "chat_id": "chat_abc123",
  "name": "release notes",
  "visibility": "team",
  "allow_all_tools": false,
  "disable_hooks": false,
  "memory": { "goal": "ship the release" }
}
```

---

## Send chat message

`POST /chats/:id/messages`

Send a user message to an existing chat. If the agent is **idle**, this starts a new run. If a run is already **active** (`status: busy`), the message is queued and picked up on the agent's next turn — the same [auto-queue](#queuing-while-a-run-is-active) behavior as `POST /agents/run` with an existing `chat_id`.

Use this endpoint when you only need to send a message without other run parameters (for example a follow-up while tools are executing, or continuing a chat without specifying agent config again).

**Legacy alias:** `POST /chats/:id/steer` — same request and response. Prefer `POST /chats/:id/messages`.

Requires `conversations:write` scope and write access to the chat. Someone who can only read the chat (for example through a read share or a public link) gets `403`; posting to a chat you cannot read returns `404`. **JavaScript SDK:** used internally by `AgentChatProvider` / `useAgentActions().sendMessage()` (`client.agent().sendMessage()` still uses `POST /agents/run`). **Python SDK:** call the REST endpoint directly.

### Request

```json
{
  "message": "Also prioritize tickets from enterprise customers",
  "attachments": [
    { "uri": "https://cloud.inference.sh/t/abc123/tickets.csv", "filename": "tickets.csv", "content_type": "text/csv" }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | No | User message text |
| `attachments` | array | No | File refs from the [Files API](/docs/api/rest/files), same shape as `input.attachments` on [Run agent](#run-agent-recommended) |

Send `message`, `attachments`, or both. A message may be attachments alone.

### Response

Returns the created message DTO with `role: "user"`. When a run is active, `status` is `"queued"` and `agent_run_id` is set; when idle, a new run starts and the message is `"ready"`. Queued messages transition to `ready` when the agent begins its next turn, before context is built. See [Chat message status](#chat-message-status).

### Errors

| HTTP status | Code | When |
|-------------|------|------|
| 400 | `invalid_request` | Neither `message` nor `attachments` is set |
| 404 | `not_found` | Chat does not exist |
| 409 | `conflict` | Harness-backed chat and the remote profile's harness is below the driver's minimum (`profiles[].support.level` is `older-than-supported`). The message explains what is missing and how to upgrade on the host. Nothing is sent to the machine. |

### Example

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/messages \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"message": "Focus on P0 tickets only"}'
```

---

## Cancel message

`POST /chats/messages/:messageId/cancel`

Cancel a **queued** user message before the agent picks it up — for example a message from [Send chat message](#send-chat-message) or an [auto-queued](#queuing-while-a-run-is-active) message you want to withdraw. The message `status` becomes `cancelled` and `content` is cleared.

Only messages with `status: "queued"` can be cancelled. Messages that are `ready`, `failed`, or already `cancelled` return `409 not_cancellable`.

Requires `conversations:write` scope. **JavaScript SDK:** `client.chats.cancelMessage('msg_abc123')` — or `useAgentActions().cancelMessage(messageId)` with `AgentChatProvider`. Python SDK: call the REST endpoint directly.

### Response

Returns the updated message DTO with `status: "cancelled"`. The [chat stream](/docs/api/rest/streaming#stream-chat-updates) emits a `chat_messages` event when the cancellation succeeds.

### Errors

| HTTP status | Code | When |
|-------------|------|------|
| 400 | `missing_id` | `messageId` path parameter is missing |
| 404 | `not_found` | Message does not exist |
| 409 | `not_cancellable` | Message is not `queued` |

### Example

```bash
curl -X POST https://api.inference.sh/chats/messages/msg_abc123/cancel \
  -H "Authorization: Bearer inf_your_key"
```

---

## Tool invocations

Tool calls on assistant messages expose `tool_invocations`. Each invocation has an `id` used by the endpoints below.

### Tool invocation status

| Status | Meaning |
|--------|---------|
| `pending` | Queued |
| `in_progress` | Executing — apps, sub-agents, client tools, hooks, MCP |
| `awaiting_input` | Interactive widget or form needs human input |
| `awaiting_approval` | HIL approval needed before execution |
| `completed` | Done |
| `failed` | Error |
| `cancelled` | Cancelled |

### Status transitions

The API enforces valid transitions server-side. Terminal states (`completed`, `failed`, `cancelled`) cannot change again. A typical successful path is `pending` → `in_progress` → `completed`, with optional stops at `awaiting_approval` or `awaiting_input`. Skipping required steps is rejected — for example `pending` cannot go directly to `awaiting_input`.

If a chat run is cancelled while a tool is executing, the invocation moves to `failed` and any late completion attempt from the tool is rejected.

Tool invocation updates are delivered on the [chat stream](/docs/api/rest/streaming#stream-chat-updates) as `chat_messages` events (the server republishes the parent message when an invocation changes).

---

## Submit tool result

`POST /tools/:toolInvocationId`

Submit a result for client tools (`in_progress`) or interactive widgets (`awaiting_input`).

### Request

```json
{
  "result": "{\"temperature\": 72}"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `result` | string | Yes | The tool's output, as text |
| `attachments` | array | No | Files the tool returns: file refs uploaded first with [`POST /files`](/docs/api/rest/files) (`uri`, `filename`, `content_type`, optional `id` and `size`). Each needs a `uri`; at most 20 |

The tool message keeps the text and the files. Images reach the model as image input when the model accepts images.

```json
{
  "result": "{\"url\": \"https://example.com\", \"note\": \"Screenshot attached\"}",
  "attachments": [
    {
      "id": "file_abc123",
      "uri": "https://cloud.inference.sh/t/abc123/screenshot.png",
      "filename": "screenshot.png",
      "content_type": "image/png"
    }
  ]
}
```

A request with more than 20 attachments, or an attachment without a `uri`, returns `400` `validation_error`.

For widget actions, JSON-serialize `{ "action": {...}, "form_data": {...} }` into `result`, or use the deprecated `POST /tools/:id/widget` endpoint.

The JavaScript and Python SDKs call this via `submitToolResult` / `submit_tool_result`. See [Client Tools](/docs/api/agent/client-tools).

---

## Approve tool (HIL)

`POST /tools/:toolInvocationId/invoke`

Approve a tool with `status: awaiting_approval` and execute it. Returns `400 invalid_state` if the invocation is not awaiting approval. Approving, rejecting, always-allowing, and submitting a tool result all require write access to the call's chat; being able to read the chat is not enough.

```bash
curl -X POST https://api.inference.sh/tools/ti_abc123/invoke \
  -H "Authorization: Bearer inf_your_key"
```

→ [Approval settings](/docs/api/agent/approval) · [Human-in-the-loop](/docs/runtime/human-in-the-loop)

---

## Reject tool (HIL)

`POST /tools/:toolInvocationId/reject`

Reject a pending approval. The agent continues with the rejection reason.

### Request

```json
{
  "reason": "Not authorized to delete files"
}
```

`reason` is optional.

---

## Always allow tool

When a tool call is `awaiting_approval`, **always allow** approves it once and saves chat **allow** rules so matching calls in this chat skip the interrupt (platform loop and [remote harness runs](#remote-harness-runs)). List and revoke rules with [Chat permissions rules](#chat-permissions-rules).

On a harness-backed chat, always allow also tells the harness it may stop asking for that tool for the remainder of the harness session (`remote_session_resolve` with `scope: session`). A one-time allow via `POST /tools/:id/invoke` sends `scope: once` instead.

Requires the `conversations:read` scope for options and `conversations:write` to save.

### List always-allow options

`GET /chats/:chatId/tools/:toolInvocationId/always-allow/options`

Returns the choices the API suggests for this call, **narrowest first** (for example an exact `remote_exec` command, a command prefix, every command on the remote, or the whole tool). The client does not build rules — each option includes the `PolicyRuleDTO` rows it would save, a `label`, optional `description` (when the engine cannot narrow further), and a stable `key` for `POST .../always-allow`.

Response (`AlwaysAllowOptionsDTO`):

| Field | Description |
|-------|-------------|
| `options` | `AlwaysAllowOptionDTO[]` with `key`, `scope` (`exact`, `prefix`, `folder`, `remote`, `tool`), `label`, optional `description`, and `rules` |
| `default` | Key of the narrowest option, or empty when there is none |
| `unavailable` | When `options` is empty, a sentence explaining why (for example governance allows only admin rules, or no chat rule could allow this call) |

For **HTTP tools**, the engine resolves the request URL to a host. When approval waits on **`WebFetch(domain:…)`** (governance **ask** or default ask on the host), chat rules cannot answer that question — `options` is empty, `unavailable` names the host, and `POST .../always-allow` without `option` does not write a whole-tool allow. When only the tool's built-in approval asked, the whole-tool option is returned as for other tools.

```bash
curl https://api.inference.sh/chats/chat_abc123/tools/ti_abc123/always-allow/options \
  -H "Authorization: Bearer inf_your_key"
```

### Save always-allow

`POST /chats/:chatId/tools/:toolInvocationId/always-allow`

Body (`AlwaysAllowRequest`):

| Field | Description |
|-------|-------------|
| `option` | Option `key` from the options endpoint. If the key no longer matches a current option (the person waited too long or the call changed), the API returns **409** `conflict`. |
| `tool_name` | Accepted for older clients and **ignored** — the invocation names its own tool. |

Omit `option` to keep the pre-options default: for [`remote_exec`](/docs/api/agent/internal-tools#remote-tools), save allow rules for each piece of the command exactly; for other tools, save a whole-tool allow rule.

Response (`AlwaysAllowResultDTO`): `option` (when one was chosen) and `rules` (the chat rules now in force for it).

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/tools/ti_abc123/always-allow \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"option":"exact:RemoteExec:git status"}'
```

**Errors (stale approval):** If the invocation is no longer `awaiting_approval` (already answered, rejected, or timed out), `GET .../always-allow/options`, `POST .../always-allow`, and [Explain tool call](#explain-tool-call) return **400** `invalid_request` with message `Tool is not awaiting approval`. `POST /tools/:id/invoke` and `POST /tools/:id/reject` still use **400** `invalid_state` for the same condition.

---

## Explain tool call

`POST /chats/:chatId/tools/:toolInvocationId/explain`

Plain-language summary of a call that is `awaiting_approval` (similar to Claude Code's permission explainer): what it does, why the agent appears to be making it, and how risky it is. Requires `conversations:write`.

The model is the chat agent's **core app** (the same model family the loop runs on). Harness-backed chats have no platform model for the harness itself, so explain is not available there.

The answer is generated once per invocation and stored; asking again returns the cached result and does not bill twice. Concurrent requests share one generation. The call runs as the person asking, under their usage policy and team billing.

Response (`ToolExplanationDTO`):

| Field | Description |
|-------|-------------|
| `risk_level` | `low`, `medium`, or `high` |
| `explanation` | What the call does, in one or two sentences |
| `reasoning` | Why the agent appears to be making it |
| `risk` | What could go wrong, in a few words |

Arguments are redacted before they reach the model; chat history is not included.

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/tools/ti_abc123/explain \
  -H "Authorization: Bearer inf_your_key"
```

---

## Agent and remote permission rules

Agents and connected **remotes** can carry their own **allow**, **ask**, and **deny** rules. Edit them in the web app (**Permissions** on the agent editor; **Rules** on the remote detail page) or with the REST routes below. Requires **`agents:read`** / **`agents:write`** for agents and **`remotes:read`** / **`remotes:write`** for remotes. Writing an agent's rules needs write access to the agent; writing a remote's rules is for the remote's **owner** only (a teammate who can see the remote gets `403`).

| Subject | List | Add | Update enforcement | Revoke |
|---------|------|-----|-------------------|--------|
| Agent | `GET /agents/{id}/rules` | `POST /agents/{id}/rules` | `PUT /agents/{id}/rules/{ruleId}` | `DELETE /agents/{id}/rules/{ruleId}` |
| Remote | `GET /remotes/{id}/rules` | `POST /remotes/{id}/rules` | `PUT /remotes/{id}/rules/{ruleId}` | `DELETE /remotes/{id}/rules/{ruleId}` |
| User (your own) | `GET /me/rules` | `POST /me/rules` | `PUT /me/rules/{ruleId}` | `DELETE /me/rules/{ruleId}` |

**Response** (`PolicyLayerDTO`): `rules[]` on that subject plus `rule_kinds[]` describing which `kind` values you may target (same shapes as [chat rules](#add-a-chat-rule)). Agent rules may use `RemoteExec`, `Workspace`, `Harness`, or `Tool`; remote rules `RemoteExec`, `Workspace`, or `Harness`.

**Scope:**

- **Agent** rules apply in every chat that runs the agent. An agent **allow** rule works only in the agent's **own workspace**; elsewhere that layer may **ask** or **deny** but not **allow**.
- **Remote** rules apply to every [`remote_exec`](/docs/api/agent/internal-tools#remote-tools) command and harness call on that machine (selector defaults to the remote id when omitted on remote-owned rules).

Add and revoke behave like chat rules: only **narrowing** allows are stored; duplicate allows are ignored; invalid allows return **400**. `POST /agents/{id}/rules/explain` and `POST /remotes/{id}/rules/explain` test a call against that subject's rules together with yours, with the same body as [Explain a call](#explain-a-call) (there is no explain route on `/me/rules`). The rules API also has `POST …/rules/preview` and `GET …/rules/recent` on chats, agents, and remotes, and `GET /rules/suggestions`; they are not described here.

```bash
curl https://api.inference.sh/agents/agent_abc/rules \
  -H "Authorization: Bearer inf_your_key"
```

The agent's **`allow_all_tools`** template flag (see [Chat settings](#chat-settings)) is separate from this layer: it is saved on the agent **version** and copied into **new chats in the agent's own workspace** when they start.

---

## Chat permissions rules

Stacked **allow**, **ask**, and **deny** rules decide whether a call runs, pauses for approval, or is refused. Layers are evaluated most specific first: **chat** (editable in this conversation), then **agent** and **user**, then the **governance** layer in force (**team** or **org** rules written by admins). Chat rules can only **narrow** what layers above allow — they cannot loosen a governance **ask** or **deny**, and when governance is the only layer that may allow, chat rules may **ask** or **deny** but not **allow**.

The workspace **Permissions** pane in [chat settings](/docs/agents/chatting#permissions) shows the same layers as `GET /chats/:id/rules`.

Requires write access to the chat (same check as [Chat settings](#chat-settings)).

### List rules

`GET /chats/:id/rules`

Requires the `conversations:read` scope.

**Response** (`ChatRulesDTO`): `layers[]` ordered most specific first. Each layer has `subject_type` (`chat`, `agent`, `remote`, `user`, `team`, `org`), `subject_id`, `label`, `governance`, `editable` (only the chat layer), and `rules[]`.

Each rule (`PolicyRuleDTO`) has:

| Field | Description |
|-------|-------------|
| `id` | Rule id |
| `effect` | `allow`, `ask`, or `deny` |
| `enforcement` | How the rule participates: `default` (decides in its layer unless a more specific admin layer overrides), `enforced` (admin-only — final governance rule), `evaluate` (never decides; the decision feed records what it would have done), or `disabled` (stored but ignored). Omitted on older rows means `default`. |
| `kind` | `RemoteExec`, `Workspace`, `Harness`, or `Tool` (chat-editable kinds in this release) |
| `selector` | Optional remote id, or `tag:<name>` to match remotes by tag |
| `specifier` | Kind-specific pattern (see below). Empty means the whole kind (for example every harness tool). |
| `label` | The rule in words (for example `git commit commands on Laptop`), the same wording always-allow options use |
| `created_at` | Set for stored rules; `null` for synthesized rows such as legacy `always_allowed_tools` entries or the **allow all tools** setting |
| `created_by` | User id for stored rules; empty for synthesized rows |

```bash
curl https://api.inference.sh/chats/chat_abc123/rules \
  -H "Authorization: Bearer inf_your_key"
```

### Add a chat rule

`POST /chats/:id/rules`

Requires the `conversations:write` scope. Body (`ChatRuleCreateRequest`):

| Field | Required | Description |
|-------|----------|-------------|
| `effect` | Yes | `allow`, `ask`, or `deny` |
| `kind` | Yes | `RemoteExec`, `Workspace`, `Harness`, or `Tool` |
| `selector` | No | Remote id or `tag:<name>` |
| `specifier` | Yes | Pattern for the kind (may be empty only when the kind allows a whole-kind rule) |
| `enforcement` | No | `default`, `evaluate`, or `disabled` on chat rules. `enforced` is reserved for admin governance layers ([usage policy](/docs/api/rest/usage-policy)). |

**Specifier examples** (typed form `Kind[selector](specifier)`):

| Kind | Example specifier | Matches |
|------|-------------------|---------|
| `RemoteExec` | `npm test:*` | Commands starting with `npm test` on the selected remote(s) |
| `RemoteExec` | `rm:*` | Commands starting with `rm` |
| `Workspace` | `~/inference/**` | Paths under that folder on the remote |
| `Tool` | `search` | One platform loop tool by name |
| `Harness` | `Bash(git status:*)` | Harness `Bash` calls matching `git status` |
| `Harness` | `Read` | The harness `Read` tool (any target) |

**Compound commands.** A [`remote_exec`](/docs/api/agent/internal-tools#remote-tools) line is split into pieces at `&&`, `||`, `;`, newlines, and `|`. Every piece must be allowed for the line to run; a deny or ask on any piece wins. Prefix rules such as `git clone:*` apply per piece. Commands the policy engine cannot fully analyse ask for approval. **Workspace** rules also apply to the paths a command touches; see [Remotes — security model](/docs/extend/remote#security-model) for how `~` and symlinks are resolved.

Returns the created `PolicyRuleDTO`. Duplicate allows for the same target are ignored (idempotent).

**Validation (400):** Chat **allow** rules that could not take effect are rejected — for example an allow while governance is the only layer that may allow, or an allow for a target a higher layer already **asks** about or **denies**.

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/rules \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"effect":"allow","kind":"Tool","specifier":"search"}'
```

### Update a rule's enforcement

`PUT /chats/:id/rules/:ruleId`  
`PUT /agents/{id}/rules/{ruleId}`  
`PUT /remotes/{id}/rules/{ruleId}`  
`PUT /me/rules/{ruleId}`

Requires the same write scope as adding a rule on that subject (`conversations:write`, `agents:write`, `remotes:write`, or `user:write` for `/me/rules`). Body (`PolicyRuleUpdateRequest`):

| Field | Required | Description |
|-------|----------|-------------|
| `enforcement` | Yes | `default`, `enforced` (governance layers only), `evaluate`, or `disabled` |

Only **enforcement** changes in place. To change a rule's target or effect, delete it and add a new rule. Governance routes on teams, orgs, and members use the same `PUT .../rules/{ruleId}` shape — see [Usage policy API](/docs/api/rest/usage-policy).

Returns the updated `PolicyRuleDTO`.

```bash
curl -X PUT https://api.inference.sh/chats/chat_abc123/rules/rule_xyz \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"enforcement":"evaluate"}'
```

### Revoke a chat rule

`DELETE /chats/:id/rules/:ruleId`

Requires the `conversations:write` scope. Only rules stored on the chat layer may be deleted (not synthesized legacy rows — remove those by changing chat settings or clearing the legacy list).

```json
{ "status": "ok" }
```

### Explain a call

`POST /chats/:id/rules/explain`

Requires the `conversations:read` scope. Test how the stacked rules would treat one call without running it. Body (`ChatRuleExplainRequest`):

| Field | Description |
|-------|-------------|
| `kind` | `RemoteExec`, `Workspace`, `Harness`, or `Tool` |
| `target` | The call: shell command (`RemoteExec`), path (`Workspace`), harness tool call such as `Bash(git status)` or `Read` (`Harness`), or loop tool name (`Tool`) |
| `remote` | Optional remote id when testing selector-scoped rules |
| `cwd` | Optional working directory for relative paths |

**Response** (`ChatRuleExplainDTO`): `effect`, the `rung` and `rule` that decided (both `null` when no single rule decided: the kind's default, a command the engine cannot analyse, or every piece of a compound command allowed), a one-line `reason`, and `would_have` when rules set to `evaluate` would have decided otherwise.

```bash
curl -X POST https://api.inference.sh/chats/chat_abc123/rules/explain \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"kind":"RemoteExec","target":"rm -rf /","remote":"r_laptop"}'
```

### Defaults (no matching rule)

| Call | Default when nothing matches |
|------|------------------------------|
| Platform loop tool with `require_approval` | **Ask** (approval interrupt) |
| Platform loop tool without `require_approval` | **Allow** |
| [`remote_exec`](/docs/api/agent/internal-tools#remote-tools) | **Ask** — commands do not run until approved unless a chat or governance **allow** rule matches |
| Harness permission prompt | **Ask**, unless a matching **allow** rule (or **allow all tools**) auto-answers |

→ [Approval settings](/docs/api/agent/approval) · [Human-in-the-loop workflow](#human-in-the-loop-workflow)

---

## Human-in-the-loop workflow

1. Run an agent with tools marked `require_approval` (see [Approval settings](/docs/api/agent/approval)).
2. Poll or stream the chat until `status` is `awaiting_input`.
3. In [List chat messages](#list-chat-messages) (or the chat stream), find invocations with `status: awaiting_approval`.
4. Call `POST /tools/:id/invoke` to approve or `POST /tools/:id/reject` to reject.
5. Poll or stream again until the agent reaches `idle` or `completed`.

List chats with `status` in `busy` or `awaiting_input` to find conversations needing action. The `belt` CLI wraps this flow with `belt chat pending`, `belt chat approve`, and `belt chat reject` — see [CLI Setup](/docs/extend/cli-setup#agents-cli).

---

## Interrupt gates

When an agent run needs external input — tool approval, widget input, missing credentials, or a lifecycle hook gate — the platform creates an **interrupt** record. Interrupts provide a unified gate model alongside the tool-invocation endpoints above.

→ [Lifecycle hooks — gate handlers](/docs/api/agent/lifecycle-hooks#gate) for hook-driven interrupts

`GET /chats/:id` includes `active_run` when a run is in progress. When the run is interrupted, `active_run.state` is `input_required` or `auth_required` and `active_run` may include:

| Field | Description |
|-------|-------------|
| `interrupt_reason` | Why the run paused (see [Interrupt reasons](#interrupt-reasons)) |
| `interrupt_tool_id` | Related tool invocation ID (when applicable) |
| `interrupt_meta` | Additional context (JSON) |

### List pending interrupts for a run

`GET /agent-runs/:runId/interrupts`

Returns pending interrupts for the given agent run. Requires the `conversations:read` scope.

**Example:**

```bash
curl https://api.inference.sh/agent-runs/ar_abc123/interrupts \
  -H "Authorization: Bearer inf_your_key"
```

**JavaScript SDK:** `client.agents.listRunInterrupts('ar_abc123')`

**CLI:** `belt interrupt list ar_abc123` — see [CLI setup — Interrupt](/docs/extend/cli-setup#interrupt-cli).

**Python SDK:** use the REST endpoint with typed responses — import `InterruptDTO` from `inferencesh.types` (see [Python SDK — Interrupt gates](/docs/api/sdk-python#interrupt-gates)). Client helpers are not yet exposed in Python.

### Resolve interrupt

`POST /interrupts/:id/resolve`

Resolve a pending interrupt with `allow` or `deny`. Requires the `conversations:write` scope and write access to the interrupt's chat; read access (for example a read share) is not enough. When all pending interrupts for the run are resolved, the agent run resumes automatically.

### Request

```json
{
  "decision": "allow",
  "data": { "reviewer": "security-bot" }
}
```

`decision` must be `"allow"` or `"deny"`. Optional `data` (any JSON object) is stored on the interrupt as `resolved_data` and returned in the response — useful for audit metadata on [hook gate](/docs/api/agent/lifecycle-hooks#gate) resolutions.

**Example:**

```bash
curl -X POST https://api.inference.sh/interrupts/int_abc123/resolve \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"decision": "allow"}'
```

**JavaScript SDK:** `client.agents.resolveInterrupt('int_abc123', 'allow')` — or `agent.resolveInterrupt()` when using `client.agent()`.

**CLI:** `belt interrupt resolve int_abc123 allow` (or `deny`).

**Python SDK:** `POST /interrupts/:id/resolve` with `{"decision": "allow"}` or `"deny"` — type the response as `InterruptDTO` from `inferencesh.types`.

For tool approval interrupts (`reason: tool_approval`), resolving with `allow` is equivalent to `POST /tools/:id/invoke`; `deny` is equivalent to `POST /tools/:id/reject`.

To answer an interrupt from an external system, call `POST /interrupts/:id/resolve` with credentials that can write to the chat. A `resolve_interrupt` trigger answers only with the interrupt's default resolution on its schedule (this is how gate expiry works); see [Triggers API — resolve_interrupt action](/docs/api/rest/triggers#resolve_interrupt-action).

### InterruptDTO fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Interrupt ID |
| `run_id` | string | Agent run ID |
| `chat_id` | string | Chat ID |
| `reason` | string | Why the run paused |
| `source` | string | Gate origin (runtime component) |
| `resource_id` | string | Related resource ID (for example a tool invocation) |
| `resource_type` | string | `tool_invocation` (tool approval gates) or `hook_event` (lifecycle hook gates) |
| `status` | string | `pending`, `resolved`, `expired`, or `cancelled` |
| `resolution` | string | `allow` or `deny` (set when resolved) |
| `resolved_data` | object | Optional JSON from the resolve request `data` field |
| `expires_at` | string | RFC3339 expiry (if set) |
| `meta` | object | Additional context (JSON). For `hook_gate`, contains the [hook payload](/docs/api/agent/lifecycle-hooks#request-payload). |

### Interrupt reasons

| Reason | When |
|--------|------|
| `tool_approval` | Tool requires human approval before execution |
| `client_tool` | Client-side tool must run in your app or browser |
| `widget` | Interactive widget needs user input |
| `auth` | Missing credentials |
| `confirmation` | Agent is waiting for explicit user confirmation |
| `hook_gate` | Lifecycle hook gate awaiting an external decision |

→ [Approval settings](/docs/api/agent/approval) · [Lifecycle hooks](/docs/api/agent/lifecycle-hooks)

---

## List internal tool definitions

`GET /agents/internal-tools`

Returns the registry of built-in internal tool categories (plan, memory, widget, finish, skills, artifacts, agent, remotes, knowledge, meta). Use it to build agent configuration UIs. Each item includes the default the runtime applies when that category's flag is unset.

Requires the `agents:read` scope.

**Response:**

```json
[
  {
    "id": "plan",
    "name": "Plan",
    "description": "Task planning tools for creating, updating, and tracking multi-step plans. Helps the agent organize complex tasks into manageable steps.",
    "tools": ["plan_create", "plan_update", "plan_load"],
    "scope": "all",
    "default_enabled": true
  },
  {
    "id": "agent",
    "name": "Agent",
    "description": "Run a copy of this agent on a self-contained side task and get its answer back, ...",
    "tools": ["agent"],
    "scope": "all",
    "default_enabled": false
  }
]
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Category key in `internal_tools` (for example `plan`, `meta`, `knowledge`) |
| `name` | string | Display name |
| `description` | string | What the category does |
| `tools` | string[] | Tool names in this category |
| `scope` | string | `all`, `top_level`, or `sub_agent` |
| `default_enabled` | boolean | What the runtime applies when the agent's flag for this category is unset. `false` for the opt-in categories (`meta`, `artifact`, `agent`, `remote`, `knowledge`), `true` for the rest |

→ [Internal Tools](/docs/api/agent/internal-tools) · [JavaScript SDK: list internal tool definitions](/docs/api/sdk-javascript#list-internal-tool-definitions)

---

## List hook events

`GET /agents/hook-events`

Returns the canonical list of lifecycle hook events with metadata. Requires the `agents:read` scope.

**Response:**

```json
[
  {
    "event": "agent.tool_call",
    "description": "Fires before a tool is executed",
    "can_gate": true
  },
  {
    "event": "agent.tool_result",
    "description": "Fires after a tool completes",
    "can_gate": false
  }
]
```

Use `can_gate` to choose between a synchronous webhook (`decision: deny`) and a gate handler (`type: "gate"`) that creates an interrupt. See [Lifecycle hooks](/docs/api/agent/lifecycle-hooks).

---

## cURL example

```bash
# Start or continue a chat
curl -X POST https://api.inference.sh/agents/run \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": "myteam/support-agent@latest",
    "input": { "text": "Hello!" }
  }'

# Get chat (includes tool invocations)
curl https://api.inference.sh/chats/chat_abc123 \
  -H "Authorization: Bearer inf_your_key"

# Approve a tool awaiting HIL
curl -X POST https://api.inference.sh/tools/ti_abc123/invoke \
  -H "Authorization: Bearer inf_your_key"
```

---

## Messaging channel webhooks

Connect external chat platforms so users can talk to an agent where they already work. Inbound platform events create agent messages; agent replies are delivered back through the same channel.

### Receive events

`POST /agents/{id}/channel/{provider}`

| Parameter | Description |
|-----------|-------------|
| `id` | Agent ID |
| `provider` | Integration provider slug (currently `slack`) |

**Legacy route (Slack only):** `POST /agents/{id}/slack` — same behavior as `provider=slack`.

No API key is required. The platform verifies the request (for Slack, using your app's **Signing Secret** from the [Slack integration](/docs/credentials/slack)). URL verification challenges (for example Slack `url_verification`) are answered automatically.

**Example (Slack event subscription Request URL):**

```
https://api.inference.sh/agents/agent_abc123/channel/slack
```

On a valid inbound message, the API acknowledges immediately (`200` with `{"status":"ok"}`) and processes the message asynchronously. Thread history is fetched when the channel supports it, so the agent has prior context.

Supported providers are registered per deployment; check integration guides for availability. See [Chatting with Agents](/docs/agents/chatting#chat-integrations) for the web app workflow.

### Channel context

`channel_context` records **where a conversation lives** outside inference.sh so assistant replies can be delivered back through the same transport (Slack thread, Telegram chat, a wearable tag, …).

| Field | Type | Description |
|-------|------|-------------|
| `channel_type` | string | Provider slug — for example `slack`, `telegram`, `teams`, `discord` |
| `channel_metadata` | object | Transport-specific routing data (JSON object on the wire) |

**Webhook ingress.** Inbound [messaging channel webhooks](#messaging-channel-webhooks) set `channel_context` on the first user message automatically. The API persists it on the chat; later turns in that chat inherit routing without resending metadata.

**Set by the platform only.** `channel_context` comes only from messages that arrive through the platform's channel integrations. `POST /agents/run`, `/agents/messages` and `/agents/message` ignore a `channel_context` field in the body: the run goes ahead and the chat gets no channel.

For Slack, `channel_metadata` matches the fields the platform stores when events arrive through the channel webhook (`channel`, `thread_ts`, and optionally `team_id`, `user_id`, `event_ts`). Other providers use their own metadata shape; replies are dispatched only when a registered transport exists for `channel_type`.

`GET /chats/:id` returns `channel_context` on channel-originated chats. Chats started in the web app or through the API omit the field.

→ [Slack event subscriptions](/docs/credentials/slack#event-subscriptions-agent-chat)

---

## Get A2A agent card

`GET /agents/{id}/card`  
`GET /agents/{namespace}/{name}/card`

Returns an [Agent2Agent (A2A)](https://a2a-protocol.org/latest/specification/) protocol agent card for agent discovery and marketplace listings (for example [GCP AI Agents Producer Portal](https://docs.cloud.google.com/marketplace/docs/partners/ai-agents)). Requires **`agents:read`**.

The response is **raw A2A JSON** — not wrapped in the usual `{ "success": true, "data": … }` envelope. Resolve the agent by ID or by `namespace/name` ref.

```bash
curl https://api.inference.sh/agents/myteam/support-agent/card \
  -H "Authorization: Bearer inf_your_key"
```

**Example response:**

```json
{
  "name": "myteam/support-agent",
  "description": "Customer support assistant",
  "version": "1.0.0",
  "url": "https://api.inference.sh/agents/myteam/support-agent/a2a",
  "documentationUrl": "https://app.inference.sh/agents/myteam/support-agent",
  "protocolVersion": "1.0",
  "preferredTransport": "JSONRPC",
  "provider": { "name": "inference shell", "url": "https://inference.sh" },
  "capabilities": { "streaming": true, "pushNotifications": false },
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain", "application/json", "application/a2ui+json"],
  "skills": [
    { "id": "search-tickets", "name": "Search tickets", "tags": ["app"] }
  ],
  "securitySchemes": [{ "scheme": "bearer" }]
}
```

`url` is the agent's A2A endpoint (see [A2A protocol](#a2a-protocol)); `documentationUrl` is its page in the web app. Skills are derived from the agent's current version tools (apps, sub-agents, MCP servers, HTTP hooks) and attached knowledge skills. In the web app, open an agent and use **a2a card** on the detail header to copy this JSON to the clipboard.

**JavaScript SDK** (`@inferencesh/sdk` ≥ v0.6.12): `client.agents.getA2ACard('agent_abc123')` — returns the same raw card object. See [JavaScript SDK — Agent SDK](/docs/api/sdk-javascript#agent-sdk).

---

## A2A protocol

Every agent speaks [A2A](https://a2a-protocol.org/latest/specification/) under its own path. The agent is the resource; the protocol is a dialect of it:

```
/agents/{id}/a2a
/agents/{namespace}/{name}/a2a
```

| Method | Path | Scope |
|--------|------|-------|
| `POST` | `/agents/{ref}/a2a` | `agents:execute` — JSON-RPC 2.0 entry point (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`…) |
| `POST` | `/agents/{ref}/a2a/message:send` | `agents:execute` |
| `POST` | `/agents/{ref}/a2a/message:stream` | `agents:execute` — SSE |
| `GET` | `/agents/{ref}/a2a/tasks` | `conversations:read` |
| `GET` | `/agents/{ref}/a2a/tasks/{runID}` | `conversations:read` |
| `POST` | `/agents/{ref}/a2a/tasks/{runID}:cancel` | `conversations:write` |
| `GET` | `/agents/{ref}/a2a/tasks/{runID}:subscribe` | `conversations:read` — SSE |

Requests may carry an `A2A-Version` header; the response echoes the supported version. An A2A *task* maps to an agent run, not to a platform task.

```bash
curl https://api.inference.sh/agents/myteam/support-agent/a2a/message:send \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"message": {"messageId": "m1", "role": "user", "parts": [{"text": "hello"}]}}'
```

---

## OpenAI-compatible dialect

The same idea for OpenAI clients: `/agents/{namespace}/{name}/openai/chat/completions` runs one turn of the agent over the Chat Completions wire format, with `channel`, `continue`, `wait` and `max_chars` for clients that only send their latest utterance. See [OpenAI-Compatible API — Agents](/docs/api/rest/openai#agents).

---

## Create or update agent template

`POST /agents`

Creates a saved agent template, or adds a new version when an agent with the same **name** already exists in your workspace's namespace. Requires the `agents:write` scope.

This mirrors [app deploy](/docs/extend/deploying#update) semantics: the namespace is assigned server-side from your workspace username, so redeploying only requires the agent `name` and a `version` payload. You do not need to look up the agent ID first, and the `namespace` field in the request is optional.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Agent name (unique per workspace namespace) |
| `namespace` | string | No | Optional. The server uses your workspace username |
| `id` | string | No | Agent ID — include to create a new version of a known agent (alternative to name-based upsert) |
| `version` | object | No | Version config (`description`, `system_prompt`, `core_app`, `tools`, `skills`, `context`, `internal_tools`, `permissions`, `hooks`, `output_schema`, …). `version.description` is limited to [200 characters](/docs/api/rest/overview#description-length-limits) |

When `name` matches an existing agent, the API creates a new version and sets it as current (same outcome as `POST /agents/{id}` with a `version` payload). Omitting `visibility` on redeploy preserves the existing visibility setting.

### Agent version permissions

Set defaults on the **version** under `permissions`. They describe what **new chats in the agent's home workspace** may do without asking; each value is copied into the chat when it is created there (`POST /chats`, `POST /agents/run` without `chat_id`, and other chat-creation paths). Chats another workspace starts with a published agent do not copy these defaults. The chat owns copied settings from then on — the same `allow_all_tools` switch in [chat settings](/docs/agents/chatting#permissions) and [`POST /chats/:id/settings`](#chat-settings). Team and org policy layers can still ask or deny over a chat's rules. **Allow** rules on the agent template apply only in the agent's home workspace; in another workspace's chat, template **ask** and **deny** rules still apply but **allow** rules do not. See also [Approval settings — Agent template defaults](/docs/api/agent/approval#agent-template-defaults).

| Field | Type | Description |
|-------|------|-------------|
| `allow_all_tools` | boolean | When `true`, each new chat in the agent's home workspace starts with [allow all tools](#chat-settings) on so unattended runs (webhooks, cron, triggers) are not blocked on tool approvals. Existing chats are unchanged when you deploy a new version |

```json
"permissions": {
  "allow_all_tools": true
}
```

In agent YAML (`belt agent deploy`), `permissions` is a top-level key next to `system_prompt`.

**Example — first deploy:**

```bash
curl -X POST https://api.inference.sh/agents \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support-agent",
    "version": {
      "description": "Customer support assistant",
      "system_prompt": "You help users with billing questions.",
      "core_app": { "ref": "openrouter/claude-sonnet-46" }
    }
  }'
```

**Example — unattended agent (allow every tool in new chats):**

```bash
curl -X POST https://api.inference.sh/agents \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "oncall-runner",
    "version": {
      "system_prompt": "Run the playbook when paged.",
      "core_app": { "ref": "openrouter/claude-sonnet-46" },
      "permissions": { "allow_all_tools": true }
    }
  }'
```

**Example — redeploy same name (creates new version):**

Send the same request shape with updated `version` fields — no agent ID lookup required.

To update by ID instead (for example when you already have `agent_abc123`), use `POST /agents/{id}` with a `version` object. To change visibility or store images without a new version, use `POST /agents/{id}/visibility` or include only agent-level fields in `POST /agents/{id}`.

CLI: `belt agent deploy <path>` and `belt agent create` both use this endpoint. See [CLI setup — Agents](/docs/extend/cli-setup#agents-cli).

This endpoint is for **new versions** of an existing template. To fork a template into a separate agent (new ID, new version row, default `private` visibility), use [Duplicate agent](#duplicate-agent) instead.

---

## Duplicate agent

`POST /agents/{id}/duplicate`

Creates a **new** saved agent from a template you can read. Requires the `agents:write` scope. Returns the new `Agent` object.

| Behavior | Detail |
|----------|--------|
| Name | First free `<name>-copy`, then `<name>-copy-2`, … in your workspace namespace |
| Visibility | Always `private` on the copy |
| Title | Appends ` (copy)` when the source has a title |
| Version | Copies the source's current version config into a **new** version row; the original agent does not gain a version |
| Workspace | The copy is created in the caller's active workspace. When the source lives in another workspace (for example a public agent), project, profile, and remote bindings are omitted |

Unlike `POST /agents` with an existing `name` (which appends a version to the same agent), duplicate always creates a distinct agent ID — the same outcome as **Duplicate** in the web app. Flows use the same `-copy` naming rule; see [Duplicate flow](/docs/api/rest/flows#duplicate-flow).

### JavaScript SDK

```typescript
const { data: copy } = await client.agents.duplicate('agent_abc123');
```

---

## Delete agent template

`DELETE /agents/{id}`

Permanently deletes a saved agent template (workspace definition). Requires the `agents:write` scope. This does not delete chat history from past runs.

```bash
curl -X DELETE https://api.inference.sh/agents/agent_abc123 \
  -H "Authorization: Bearer inf_your_key"
```

CLI equivalent: `belt agent delete <ref>` (accepts agent ID or `namespace/name`). See [CLI setup — Agents](/docs/extend/cli-setup#agents-cli).

---

## Related

- [Agent SDK overview](/docs/api/agent/overview)
- [Streaming API](/docs/api/rest/streaming)
- [Observability](/docs/runtime/observability)
- [Structured output](/docs/api/agent/structured-output)
- [Client tools](/docs/api/agent/client-tools)
- [Slack integration](/docs/credentials/slack)
