> ## Documentation Index
> Fetch the complete documentation index at: https://docs-eos.fpt.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Chuẩn bị kỹ thuật cho External Agent

> Những gì đội kỹ thuật của đối tác phải dựng xong trước khi kết nối vào FPT AI EOS

Trang này dành cho đội kỹ thuật vận hành một External Agent. Trang liệt kê đầy đủ những thứ phải dựng xong trước khi onboard: hai endpoint, cách xác thực, định dạng luồng trả về, các mốc thời gian và những việc External Agent phải tự lo. Phần thao tác khai báo trên Console nằm ở trang [Kết nối Agent bên ngoài](https://docs-eos.fpt.ai/eos/vi/connect-external-agent.md).

Ba cái tên sẽ gặp suốt trang. **Console** là nơi người vận hành khai báo External Agent, cấu hình và publish. **Workspace** là nơi người dùng cuối chat. **FPT AI EOS** là nền tảng chạy phía sau, và đây mới là bên mở kết nối tới máy chủ của bên bạn.

> [!NOTE]
>
> FPT AI EOS là **client**, External Agent là **server**. FPT AI EOS gọi sang, External Agent trả lời. Không có chiều ngược lại: ngoài luồng đang mở, không có kênh nào để đẩy dữ liệu ngược lên nền tảng.

Sau khi khai báo xong, External Agent của bên bạn hiện ra trong Workspace như một Nhân sự số bình thường, người dùng chat với nó y hệt các Nhân sự số khác. Tài liệu này vẫn gọi phần chạy trên hạ tầng của đối tác là External Agent, để khỏi lẫn với những Nhân sự số do nền tảng tự chạy.

## Phân chia trách nhiệm

Nền tảng không chạy External Agent. Phía FPT AI EOS chỉ lo phần vỏ: Giao diện chat trong Workspace, lịch sử hội thoại, việc khai báo và publish trong Console, và mỗi lượt thì gọi `POST /runs` đúng một lần. Toàn bộ phần còn lại thuộc về bên đối tác.

| Việc | Bên phụ trách |
| - | - |
| Giao diện chat và lịch sử hội thoại | Workspace |
| Khai báo External Agent, cấu hình, kiểm tra, publish | Console |
| Gọi `POST /runs` mỗi lượt, hiển thị frame nhận được | FPT AI EOS |
| Logic nghiệp vụ, prompt, gọi model, chạy tool | External Agent |
| **Context và state của hội thoại** | External Agent |
| Host, scale, uptime, log, monitoring | External Agent |
| Host file do External Agent sinh ra và cấp link tải được | External Agent |

> [!WARNING]
>
> FPT AI EOS **không giữ context hội thoại hộ bên bạn** và không có endpoint nào để đọc lại. Đây là điều quan trọng nhất trong trang này, xem mục Tự giữ context bên dưới.

## Một lượt chat đi qua External Agent

![Sơ đồ trình tự một lượt chat qua External Agent](https://mintcdn.com/fpt-15dbd015/gDW0AWl75k3m2kQW/images/eos/ext_run_vi_v2.png?fit=max\&auto=format\&n=gDW0AWl75k3m2kQW\&q=85\&s=f61f94f763426806863bdcc3184c2d61)

## Hai endpoint phải có

| Method | Path | Trả về | Dùng để |
| - | - | - | - |
| `POST` | `{base_url}/runs` | `text/event-stream` | Một request là một lượt chat |
| `GET` | `{base_url}/health` | `application/json` | Kiểm tra sống và phiên bản protocol |

Hai path này cố định dưới `base_url`, không cấu hình riêng từng path được. Ví dụ `base_url` là `https://agent.example.com/fpt` thì nền tảng gọi `POST https://agent.example.com/fpt/runs` và `GET https://agent.example.com/fpt/health`.

Ràng buộc với `base_url`:

* Bắt buộc **https**. FPT AI EOS từ chối `http`.
* Không được nhúng credential trong URL dạng `user:pass@`.
* Nền tảng phân giải DNS và kiểm địa chỉ ở **mỗi lần kết nối**, không phải một lần lúc onboard. Địa chỉ nội bộ, loopback, link-local, reserved, multicast đều bị từ chối. Một tên miền đổi DNS sang địa chỉ nội bộ sẽ bị chặn ngay ở lượt kế tiếp.
* **Redirect không được đi theo.** Một `301` hay `302` bị tính là lỗi. Cần đổi vị trí thì đổi `base_url`.

## Xác thực

Nền tảng gửi một tập header do hai bên thống nhất lúc onboard, y nguyên trên cả hai endpoint. Bên đối tác quyết định tập đó gồm những gì: FPT AI EOS không quy định tên header, không quy định scheme, và không tự thêm gì vào giá trị.

| Cách dùng | Ví dụ |
| - | - |
| Bearer token | `Authorization: Bearer <token>` |
| API key tên tùy ý | `X-API-Key: <key>` |
| Nhiều header cùng lúc | `X-API-Key: <key>` và `X-Tenant: <mã>` |

Những điều phải làm đúng:

* **Giá trị gửi nguyên văn.** Người vận hành nhập trọn chuỗi `Bearer abc123` trong Console, nền tảng gửi đi đúng như vậy, không ghép thêm tiền tố.
* **Kiểm đủ mọi header trong tập.** Yêu cầu hai header thì phải từ chối request chỉ có một.
* **So sánh bằng hàm thời gian hằng** như `hmac.compare_digest` hoặc `crypto.timingSafeEqual`. Dùng `==` sẽ dừng ở byte lệch đầu tiên và thời gian trả lời lộ dần giá trị đúng.
* Sai hoặc thiếu thì trả `401`.

> [!NOTE]
>
> Không xác thực cũng là một lựa chọn hợp lệ: khi đó nền tảng không gửi header nào cả. Chỉ chọn khi bên bạn đã chặn truy cập bằng cách khác như IP allowlist, mTLS hay mạng nội bộ.

> [!NOTE]
>
> Không có chữ ký HMAC. Đừng chờ header `X-FPT-Signature`, nó đã được bỏ khỏi contract. Chống phát lại một lượt vẫn làm được bằng `runId`.

## Định danh nền tảng gửi sang

| Field | Có mặt | Ý nghĩa |
| - | - | - |
| `threadId` | Luôn có | Id hội thoại. **Là khóa kho context của bên bạn** |
| `runId` | Luôn có | Id lượt này. Dùng làm khóa idempotency |
| `agentId` | Luôn có | External Agent nào đang bị gọi. Một `base_url` phục vụ được nhiều External Agent |
| `workspaceId` | Luôn có | Tổ chức đang gọi. Dùng cho quota, rate limit, cấu hình theo khách |
| `userId` | Có thể vắng | Người đang chat. Vắng khi lượt không có người thật phía sau |

> [!WARNING]
>
> Mọi id gửi dạng **chuỗi** và là số nguyên 63 bit. Đừng parse thành số: `JSON.parse` của JavaScript làm tròn sai ở mức trên 2^53 và hai id khác nhau có thể thành một.

`threadId` có hậu tố nhánh khi người dùng sửa rồi chạy lại một lượt cũ: `918273645500123456` ở hội thoại thẳng, `918273645500123456:<branch>` sau khi fork. Với bên bạn đó là **hai hội thoại khác nhau**, và đúng là phải như vậy: một nhánh không được thấy context của nhánh kia.

## Body của `POST /runs`

| Field | Kiểu | Bắt buộc | Giá trị |
| - | - | - | - |
| `protocolVersion` | string | Có | Luôn là `"1"` |
| `runId` | string | Có | Id lượt, mới ở mỗi lượt |
| `threadId` | string | Có | Id hội thoại |
| `agentId` | string | Có | External Agent đang bị gọi |
| `workspaceId` | string | Có | Tổ chức đang gọi |
| `input` | string | Có | Câu người dùng gõ. **Có thể là chuỗi rỗng** |
| `userId` | string | Không | Người đang chat |
| `attachments` | array | Không | File người dùng gửi kèm lượt này |
| `resume` | object | Không | Chỉ có ở lượt trả lời một câu hỏi lại |
| `history` | array | Không | **Thực tế gần như luôn vắng** |

Một lượt chat bình thường chỉ có bấy nhiêu:

```json theme={null}
{
  "protocolVersion": "1",
  "runId": "01K5BH5PSX926W6RZ6YR1P8WWV",
  "threadId": "918273645500123456",
  "agentId": "918273688800000200",
  "workspaceId": "918273600000000001",
  "userId": "918273644400000042",
  "input": "Đơn DH-99217 giờ tới đâu rồi?"
}
```

> [!NOTE]
>
> `input` rỗng là hợp lệ và có nghĩa. Khi người dùng chỉ bấm *Đồng ý* hay *Từ chối* cho một câu hỏi lại, không có chữ nào để nhấc lên `input`, và quyết định nằm ở `resume.decision`.

## Tự giữ context theo `threadId`

Ở phiên bản đang chạy, `history` chỉ được điền khi người dùng fork một hội thoại. Trên hội thoại thẳng, tức là mọi cuộc chat bình thường, nền tảng gửi **không lượt nào**.

Hệ quả: External Agent **phải** tự lưu context của mình, khóa theo `threadId`, tự đặt TTL, và dữ liệu phải sống qua deploy. Không có nguồn nào khác.

> [!WARNING]
>
> Một External Agent dựa vào `history` để nhớ lượt trước sẽ trả lời như thể mỗi tin nhắn là tin đầu tiên. Triệu chứng này rất dễ bị đổ nhầm cho model, nên kiểm tra khóa context trước khi nghi ngờ chất lượng trả lời.

Cùng nhóm với context là **idempotency**: `runId` đã chạy xong thì trả lại kết quả cũ, đừng chạy lại. Nền tảng không có cơ chế retry nghiệp vụ, nhưng khóa này chặn được việc một lượt bị phát lại.

## Trả lời bằng luồng SSE

Trả `Content-Type: text/event-stream`, nên kèm `Cache-Control: no-cache` và `X-Accel-Buffering: no`. Mỗi frame là một dòng `data:` chứa một JSON object, các frame ngăn nhau bằng một dòng trắng.

Bộ đọc của nền tảng là bộ đọc SSE tối giản: `event:`, `id:`, `retry:` bị bỏ qua, toàn bộ nội dung nằm trong JSON của dòng `data:`. Dòng bắt đầu bằng dấu hai chấm là comment và được dùng làm nhịp giữ kết nối.

### Luồng tối thiểu của một câu trả lời

```text theme={null}
data: {"type":"TEXT_MESSAGE_START","messageId":"a1","role":"assistant"}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"a1","delta":"Hôm qua vàng "}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"a1","delta":"giảm 0,5%."}

data: {"type":"TEXT_MESSAGE_END","messageId":"a1"}

data: {"type":"RUN_FINISHED","outcome":{"type":"success"}}
```

### Envelope chung của mọi frame

| Field | Bắt buộc | Giá trị |
| - | - | - |
| `type` | Có | Tên frame. Không nhận ra thì frame bị bỏ, không làm hỏng lượt |
| `event_id` | Không | Id frame. Gửi lại đúng frame thì dùng lại đúng id để trình duyệt khử trùng lặp |
| `run_id` | Không | Có thì phải bằng `runId` trong request, lệch thì frame bị loại |
| `threadId` | Không | Có thì phải bằng `threadId` trong request |
| `messageId` | Không | Ở các frame có message |

`messageId` và `toolCallId` chỉ cần duy nhất **trong một lượt**, nên dùng lại `"a1"` ở mọi lượt là an toàn.

### Danh sách frame nền tảng xử lý

| Frame | Mức | Field bắt buộc |
| - | - | - |
| `TEXT_MESSAGE_START` | Bắt buộc | `messageId` |
| `TEXT_MESSAGE_CONTENT` | Bắt buộc | `messageId`, `delta` |
| `TEXT_MESSAGE_END` | Bắt buộc | `messageId` |
| `RUN_FINISHED` | Bắt buộc | `outcome` |
| `RUN_ERROR` | Bắt buộc | `message` |
| `TOOL_CALL_START` | Nên có | `toolCallId`, `toolCallName` |
| `TOOL_CALL_ARGS` | Nên có | `toolCallId`, `delta` dạng **chuỗi** |
| `TOOL_CALL_END` | Nên có | `toolCallId` |
| `TOOL_CALL_RESULT` | Nên có | `toolCallId`, `content` |
| `STEP_STARTED` và `STEP_FINISHED` | Nên có | `stepName` |
| `ACTIVITY_SNAPSHOT` | Nên có | `messageId`, `activityType`, `content` |
| `ACTIVITY_DELTA` | Tùy chọn | `messageId`, `patch` |
| `STATE_SNAPSHOT` | Tùy chọn | `snapshot` |
| `STATE_DELTA` | Tùy chọn | `delta` |
| `REASONING_MESSAGE_START`, `_CONTENT`, `_END` | Tùy chọn | `messageId` |
| `CUSTOM` | Tùy chọn | `name` |

Lượt phải kết thúc bằng `RUN_FINISHED` **hoặc** `RUN_ERROR`.

> [!NOTE]
>
> **Đừng gửi `RUN_STARTED`**, nền tảng đã tự phát frame đó khi mở lượt. **Đừng gửi `REASONING_END`**, nền tảng tự phát kèm `REASONING_MESSAGE_END`.

### Text xen kẽ tool call

Mỗi đoạn text liền mạch là một message riêng, có `messageId` riêng và cặp `START` / `END` riêng. Đoạn text đóng lại trước tool call kế tiếp, text đến sau tool call thuộc về một message mới, và **câu trả lời là message cuối cùng** của lượt.

### Kết thúc lượt cho đúng

* Gửi `RUN_FINISHED` hoặc `RUN_ERROR` **rồi mới đóng luồng.**
* Đóng mọi thứ đã mở trước frame cuối: `TEXT_MESSAGE_START`, `TOOL_CALL_START`, `STEP_STARTED`, `REASONING_MESSAGE_START`. Nền tảng có đóng hộ nhưng **không bịa ra `TOOL_CALL_RESULT`**.
* Đừng gửi một tin nhắn rỗng. Không có gì để nói thì gửi `RUN_ERROR`.
* Tool call phải đứng **trước** câu trả lời tham chiếu tới nó. Nền tảng đọc response như một luồng nên thứ tự là ràng buộc thật.
* Trong lúc chưa có gì để gửi, cứ gửi một dòng comment làm nhịp giữ kết nối, tối thiểu 15 giây một lần.

> [!WARNING]
>
> Đóng luồng sạch mà quên frame kết thúc **không** bị coi là lỗi: Nền tảng ghi lượt đó là thành công với đúng phần đã nhận được. Một External Agent chết giữa câu rồi chỉ đóng socket sẽ để lại một câu trả lời cụt được lưu như một câu trả lời hoàn chỉnh. Nhìn thì nhỏ, nhưng `RUN_ERROR` chính là thứ chặn được chuyện đó.

## Mốc thời gian và giới hạn

Đây là giá trị mặc định, không phải hằng số của contract. Hỏi người vận hành nếu bên bạn cần con số thật của môi trường sẽ chạy.

| Mốc | Mặc định |
| - | - |
| Kết nối TCP và TLS | 5 giây |
| **Frame đầu tiên** | **10 giây** kể từ lúc response bắt đầu |
| Khoảng lặng giữa hai chunk | 30 giây, một dòng comment reset đồng hồ này |
| Tổng một lượt | 5 phút, đặt riêng cho từng External Agent lúc onboard |
| Kích thước một frame | 256 KiB |
| Kích thước cả luồng | 8 MiB |
| Số frame một lượt | 20.000 |
| Hiệu lực link file đính kèm | 7 ngày |

> [!WARNING]
>
> **Mốc 10 giây cho frame đầu tiên là chỗ vấp của gần như mọi tích hợp.** Nó không phải hạn cho cả câu trả lời, mà là hạn cho **frame đầu**. Nếu kiến trúc bên bạn chạy xong toàn bộ lượt rồi mới trả byte đầu, ví dụ một workflow no-code hay một hàm serverless buffer sẵn response, bên bạn sẽ timeout dù External Agent hoàn toàn khỏe.
>
> Cách đúng: **Đẩy một frame ra ngay lập tức**, trước khi gọi model. Một `STEP_STARTED` hay một dòng comment là đủ để mở luồng và chuyển sang đồng hồ khoảng lặng 30 giây, vốn rộng hơn nhiều.

Hai điều nữa về vòng đời một lượt:

* **Không có hủy giữa chừng.** Người dùng bấm Dừng thì nền tảng đóng kết nối, và đó là toàn bộ tín hiệu hủy. Client ngắt kết nối thì dừng xử lý.
* **Không có retry.** Một lượt hỏng là một lượt hỏng. Người dùng gửi lại thì đó là một `runId` mới.

## File

### File người dùng gửi lên

Mỗi phần tử của `attachments[]` có `id`, `url`, `name`, `mime`, `sizeBytes`. Link là https, tải được không cần header xác thực, và **có hạn**.

* **Chống trùng bằng `id`**, không phải bằng `url` hay `name`. `id` không đổi qua các lượt cho cùng một file.
* Cần dùng file ở lượt sau thì phải **tải và tự lưu trong hạn**. Cứ coi như link sẽ hết hạn.
* Link không có auth nên ai có link đều tải được. Đừng log, đừng chuyển tiếp ra ngoài hệ thống của bên bạn.
* Đừng tin metadata: `name` không dùng làm đường dẫn, `mime` do nền tảng khai nên tự kiểm nội dung.

> [!NOTE]
>
> File nền tảng không cấp được link sẽ bị loại khỏi `attachments` và được nêu tên trong `input` dưới dạng một dòng thông báo của hệ thống. Đó là câu của nền tảng, không phải của người dùng, nên đừng lặp lại nguyên văn.

### File External Agent sinh ra

External Agent tự host file và cấp URL truy cập được từ trình duyệt người dùng, rồi gửi sang bằng `STATE_SNAPSHOT` với khóa `artifacts`, hoặc bằng `CUSTOM` với `name` là `file`.

> [!WARNING]
>
> **Một file, một kênh.** Hai cách trên là hai nguồn độc lập và không khử trùng lặp cho nhau. Gửi cả hai cho cùng một file thì người dùng thấy **hai thẻ**. Nếu văn xuôi của bên bạn còn chèn ảnh markdown trỏ tới file đó nữa thì thành ba.

Với `STATE_SNAPSHOT` và `STATE_DELTA`, chỉ ba khóa được chấp nhận: `todos`, `artifacts`, `files`. Khóa khác bị loại.

## Hỏi lại người dùng

Cần người dùng quyết định mới đi tiếp thì kết thúc lượt bằng `RUN_FINISHED` với `outcome.type` là `interrupt`. Mỗi lần dừng chỉ mang theo **đúng một** interrupt.

| `kind` | Hiển thị | Field nội dung |
| - | - | - |
| `approval` | Hai nút đồng ý và từ chối | Chỉ `prompt` |
| `choice` | Câu hỏi kèm danh sách lựa chọn, luôn có thêm ô tự gõ | `options` |
| `input` | Câu hỏi chỉ có ô tự gõ | Chỉ `prompt` |
| `review` | Một hay nhiều tool call đề xuất, kèm tham số xem và sửa được | `items` |
| `authorization` | Thẻ kết nối tài khoản, mở popup tới `authorizeUrl` | `provider`, `authorizeUrl` |

```json theme={null}
{ "type": "RUN_FINISHED",
  "outcome": { "type": "interrupt",
    "interrupts": [ { "kind": "approval",
                      "prompt": "Xác nhận đặt vé HAN đi SGN ngày 20/8?" } ] } }
```

Ở lượt kế tiếp, nền tảng gửi lại quyết định trong `resume`:

| `kind` của interrupt | `decision` nhận được | Nội dung nằm ở |
| - | - | - |
| `approval` | `approve` hoặc `reject` | Không có, `input` rỗng |
| `choice` và `input` | `respond` | `message`, `answers`, và `input` |
| `review` | `approve`, `reject`, `edit`, `respond` | `editedAction` khi `edit` |
| `authorization` | `authorized` | Không có |

> [!WARNING]
>
> Một `kind` ngoài năm giá trị trên, hoặc một `review` thiếu `name`, hoặc `authorizeUrl` không hợp lệ sẽ làm **hỏng cả lượt**. Đây là trường hợp duy nhất một frame sai làm hỏng lượt thay vì bị bỏ qua.

`authorizeUrl` bị kiểm bốn điều: Phải có mặt, phải https, không nhúng credential, và host không được là địa chỉ IP. Không có allowlist domain, bên bạn không phải khai trước danh sách.

> [!NOTE]
>
> `decision` là `authorized` không phải bằng chứng đã cấp quyền. Tự kiểm kho token của mình trước khi đi tiếp.

## `GET /health`

```json theme={null}
{ "status": "ok", "protocolVersions": ["1"], "name": "Flight Assistant", "version": "2.3.1" }
```

| Field | Bắt buộc | Giá trị |
| - | - | - |
| `status` | Có | `ok`, `degraded` hoặc `down` |
| `protocolVersions` | Có | **Phải chứa `"1"`** |
| `name` | Không | Tên hiển thị |
| `version` | Không | Phiên bản của bên bạn |

Endpoint này vẫn mang đúng tập header xác thực như `/runs`, phải nhẹ, không gọi model, không gọi database, và cũng không được redirect.

Nền tảng xếp kết quả thành ba trạng thái. Khỏe thì lượt chat đi ra bình thường. `degraded` cũng vậy, vì một External Agent chậm vẫn hơn một External Agent bị ngắt hẳn. Còn lại là `unavailable`, xảy ra khi không kết nối được, khi trả `401` hay `403`, khi body không phải JSON, khi `protocolVersions` không chứa `"1"`, hoặc khi bên bạn tự khai `down`.

> [!WARNING]
>
> `/health` trả `ok` là **chưa đủ để publish**. Khi người vận hành bấm Kiểm tra trong Console, nền tảng còn gửi một lượt `POST /runs` thật với `input` là `Hello` và một `threadId` dùng một lần dạng `validate-<16 ký tự hex>`, rồi dừng ngay ở frame hợp lệ đầu tiên. Lượt này không được lưu vào lịch sử. Đừng để `threadId` lạ đó làm hỏng quy trình của bên bạn.

> [!NOTE]
>
> Chưa có kiểm tra sức khỏe tự động theo chu kỳ. Ba lần lỗi liên tiếp sẽ chuyển endpoint sang `unavailable` và mọi lượt chat bị từ chối ngay. Hết sự cố thì nền tảng **không tự nhận ra**, phải báo người vận hành chạy lại kiểm tra trong Console.

## Mã lỗi để đối soát log

`code` không hiện cho người dùng, nhưng là thứ đầu tiên người vận hành đọc khi có sự cố.

| `code` | Xảy ra khi | Sửa ở đâu |
| - | - | - |
| `ENDPOINT_UNAVAILABLE` | Endpoint đang bị đánh dấu không khả dụng, request không được gửi đi | Phía FPT, chạy lại kiểm tra |
| `PROTOCOL_MISMATCH` | Lần kiểm tra gần nhất thấy `protocolVersions` không có `"1"` | External Agent |
| `EGRESS_REFUSED` | `base_url` trỏ vào địa chỉ nền tảng không kết nối tới được | Hạ tầng |
| `CONNECT_TIMEOUT` | Không bắt tay được trong 5 giây | Hạ tầng |
| `UPSTREAM_REJECTED` | External Agent trả HTTP 3xx hoặc 4xx cho `POST /runs`, thường là `401` | External Agent |
| `UPSTREAM_UNAVAILABLE` | External Agent trả HTTP 5xx | External Agent |
| `TTFB_TIMEOUT` | Luồng mở nhưng không có frame nào trong 10 giây | External Agent |
| `IDLE_TIMEOUT` | Im lặng quá 30 giây giữa luồng, thiếu nhịp giữ kết nối | External Agent |
| `STREAM_TRUNCATED` | Luồng đứt giữa chừng, phần đã nhận vẫn được lưu | Hạ tầng |
| `LIMIT_EXCEEDED` | Vượt trần kích thước frame, tổng byte hoặc số frame | External Agent |
| `INTERRUPT_REJECTED` | Không dựng được thẻ hỏi lại người dùng | External Agent |

Lượt hỏng vẫn vào lịch sử: Phần văn bản đã kịp gửi vẫn hiện trong hội thoại kèm dấu lỗi.

## Những thứ không phải làm

| Đừng dựng | Vì sao |
| - | - |
| `GET /tools`, `POST /credentials`, `POST /credentials/revoke` | Nền tảng không gọi tới ở phiên bản hiện tại |
| Kiểm chữ ký `X-FPT-Signature` | Đã bỏ khỏi contract |
| Logic dựa trên `history` | Gần như luôn vắng, xem mục Tự giữ context |
| Endpoint hủy hay resume | Không có trong contract |

## Checklist trước khi onboard

**1. Hai endpoint chạy được**

`GET /health` trả `status` là `ok` và `protocolVersions` chứa `"1"`. `POST /runs` nhận body ở trên và trả `text/event-stream`.

**2. Xác thực kiểm đủ và kiểm đúng cách**

Kiểm mọi header trong tập đã thống nhất, so sánh bằng hàm thời gian hằng, sai thì trả `401`.

**3. Frame đầu ra trong 10 giây**

Đẩy một frame ngay trước khi gọi model. Đây là mốc dễ trượt nhất.

**4. Context lưu theo threadId**

Tự lưu, tự đặt TTL, sống qua deploy. Kiểm tra bằng cách hỏi hai lượt liên tiếp có tham chiếu lẫn nhau.

**5. Lượt nào cũng có frame kết thúc**

`RUN_FINISHED` hoặc `RUN_ERROR` trước khi đóng luồng, và mọi thứ đã mở đều được đóng.

**6. Nhịp giữ kết nối**

Gửi một dòng comment tối thiểu 15 giây một lần khi đang xử lý lâu.

**7. Idempotency theo runId**

`runId` đã chạy xong thì trả kết quả cũ, không chạy lại.

> [!NOTE]
>
> Vướng ở đâu thì gửi cho người vận hành bốn thứ: `runId`, `threadId`, thời điểm, và mã lỗi ở bảng trên nếu có. Đó là bộ đủ để hai bên đối chiếu log.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.