> ## 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.

# Technical requirements for an External Agent

> What the partner engineering team has to have running before connecting to FPT AI EOS

This page is for the engineering team that runs an External Agent. It lists everything that has to be in place before onboarding: the two endpoints, authentication, the response stream format, the deadlines, and the things the External Agent has to handle on its own. The Console side of registering it is covered in [Connect an external agent](https://docs-eos.fpt.ai/eos/en/connect-external-agent.md).

Three names come up throughout. **Console** is where an operator registers the External Agent, configures it and publishes it. **Workspace** is where end users chat. **FPT AI EOS** is the platform behind both, and it is the side that opens the connection to your server.

> [!NOTE]
>
> FPT AI EOS is the **client**, the External Agent is the **server**. FPT AI EOS calls, the External Agent answers. There is no reverse direction: outside an open stream there is no channel for pushing data back into the platform.

Once it is registered, your External Agent shows up in Workspace as an ordinary Digital Worker, and users chat with it exactly as they do with any other. This page still calls the part running on the partner's infrastructure the External Agent, to keep it apart from the Digital Workers the platform runs itself.

## Division of responsibility

The platform does not run the External Agent. FPT AI EOS only handles the shell: the chat interface in Workspace, the conversation history, registration and publishing in Console, and exactly one `POST /runs` call per turn. Everything else belongs to the partner.

| Responsibility | Owner |
| - | - |
| Chat interface and conversation history | Workspace |
| Registering the External Agent, configuring, testing, publishing | Console |
| Calling `POST /runs` per turn, rendering the frames it receives | FPT AI EOS |
| Business logic, prompts, model calls, tool execution | External Agent |
| **Conversation context and state** | External Agent |
| Hosting, scaling, uptime, logs, monitoring | External Agent |
| Hosting files the External Agent produces and serving a downloadable link | External Agent |

> [!WARNING]
>
> FPT AI EOS **does not keep conversation context for you** and offers no endpoint to read it back. This is the single most important point on this page, see Keeping your own context below.

## One chat turn through an External Agent

![Sequence diagram of one chat turn through an External Agent](https://mintcdn.com/fpt-15dbd015/gDW0AWl75k3m2kQW/images/eos/ext_run_en_v2.png?fit=max\&auto=format\&n=gDW0AWl75k3m2kQW\&q=85\&s=13d0d564d8401607c6daf967bf99b8f9)

## The two required endpoints

| Method | Path | Returns | Used for |
| - | - | - | - |
| `POST` | `{base_url}/runs` | `text/event-stream` | One request is one chat turn |
| `GET` | `{base_url}/health` | `application/json` | Liveness and protocol version |

Both paths are fixed under `base_url`, they cannot be configured individually. If `base_url` is `https://agent.example.com/fpt`, the platform calls `POST https://agent.example.com/fpt/runs` and `GET https://agent.example.com/fpt/health`.

Constraints on `base_url`:

* **https** is required. FPT AI EOS rejects `http`.
* Credentials must not be embedded in the URL as `user:pass@`.
* The platform resolves DNS and checks the address **on every connection**, not once at onboarding. Private, loopback, link-local, reserved and multicast addresses are all rejected. A domain that starts resolving to an internal address is blocked on the very next turn.
* **Redirects are not followed.** A `301` or `302` counts as an error. To move, change `base_url`.

## Authentication

The platform sends a set of headers agreed at onboarding, identical on both endpoints. The partner decides what that set contains: FPT AI EOS does not dictate header names, does not dictate a scheme, and adds nothing to the values.

| Style | Example |
| - | - |
| Bearer token | `Authorization: Bearer <token>` |
| API key under any name | `X-API-Key: <key>` |
| Several headers at once | `X-API-Key: <key>` and `X-Tenant: <id>` |

What has to be right:

* **Values are sent verbatim.** The operator types the whole string `Bearer abc123` in Console, and the platform sends exactly that, with no prefix added.
* **Check every header in the set.** If two are required, a request carrying only one must be rejected.
* **Compare with a constant-time function** such as `hmac.compare_digest` or `crypto.timingSafeEqual`. `==` stops at the first differing byte, and response time leaks the correct value.
* Wrong or missing credentials return `401`.

> [!NOTE]
>
> No authentication is also a valid choice: the platform then sends no headers at all. Only pick it when access is already restricted another way, such as an IP allowlist, mTLS or a private network.

> [!NOTE]
>
> There is no HMAC signature. Do not expect an `X-FPT-Signature` header, it was removed from the contract. Replay protection for a turn is still possible through `runId`.

## The identifiers the platform sends

| Field | Present | Meaning |
| - | - | - |
| `threadId` | Always | Conversation id. **This is the key for your context store** |
| `runId` | Always | Id of this turn. Use it as an idempotency key |
| `agentId` | Always | Which External Agent is being called. One `base_url` can serve many External Agents |
| `workspaceId` | Always | The calling organization. Use it for quota, rate limits, per-customer config |
| `userId` | May be absent | The person chatting. Absent when no real person is behind the turn |

> [!WARNING]
>
> All ids are sent as **strings** and are 63-bit integers. Do not parse them as numbers: JavaScript's `JSON.parse` rounds incorrectly above 2^53 and two different ids can collapse into one.

`threadId` carries a branch suffix when a user edits and re-runs an earlier turn: `918273645500123456` on a linear conversation, `918273645500123456:<branch>` after a fork. For you these are **two different conversations**, and that is correct: one branch must not see the other branch's context.

## The `POST /runs` body

| Field | Type | Required | Value |
| - | - | - | - |
| `protocolVersion` | string | Yes | Always `"1"` |
| `runId` | string | Yes | Turn id, new on every turn |
| `threadId` | string | Yes | Conversation id |
| `agentId` | string | Yes | The External Agent being called |
| `workspaceId` | string | Yes | The calling organization |
| `input` | string | Yes | What the user typed. **May be an empty string** |
| `userId` | string | No | The person chatting |
| `attachments` | array | No | Files the user attached to this turn |
| `resume` | object | No | Only present when answering a question you asked |
| `history` | array | No | **In practice almost always absent** |

An ordinary chat turn is only this much:

```json theme={null}
{
  "protocolVersion": "1",
  "runId": "01K5BH5PSX926W6RZ6YR1P8WWV",
  "threadId": "918273645500123456",
  "agentId": "918273688800000200",
  "workspaceId": "918273600000000001",
  "userId": "918273644400000042",
  "input": "Where is order DH-99217 right now?"
}
```

> [!NOTE]
>
> An empty `input` is valid and meaningful. When the user only clicks *Approve* or *Reject* on a question you asked, there is no text to lift into `input`, and the decision sits in `resume.decision`.

## Keeping your own context, keyed by `threadId`

In the version running today, `history` is populated only when a user forks a conversation. On a linear conversation, which is every ordinary chat, the platform sends **no prior turns at all**.

The consequence: the External Agent **must** store its own context, keyed by `threadId`, with its own TTL, surviving deploys. There is no other source.

> [!WARNING]
>
> An External Agent that relies on `history` to remember the previous turn will answer as if every message were the first one. That symptom is easily blamed on the model, so check the context key before suspecting answer quality.

In the same family as context is **idempotency**: if a `runId` already completed, return the earlier result instead of running again. The platform has no business-level retry, but this key protects against a replayed turn.

## Answering with an SSE stream

Return `Content-Type: text/event-stream`, and include `Cache-Control: no-cache` and `X-Accel-Buffering: no`. Every frame is a `data:` line holding one JSON object, and frames are separated by a blank line.

The platform's reader is a minimal SSE reader: `event:`, `id:` and `retry:` are ignored, and everything lives in the JSON on the `data:` line. A line starting with a colon is a comment and is used as the keep-alive heartbeat.

### The minimal stream for one answer

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

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"a1","delta":"Gold fell "}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"a1","delta":"0.5% yesterday."}

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

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

### The envelope shared by every frame

| Field | Required | Value |
| - | - | - |
| `type` | Yes | Frame name. An unknown one is dropped without breaking the turn |
| `event_id` | No | Frame id. When resending the same frame, reuse the id so the browser can de-duplicate |
| `run_id` | No | If present it must equal `runId` in the request, otherwise the frame is discarded |
| `threadId` | No | If present it must equal `threadId` in the request |
| `messageId` | No | On frames that carry a message |

`messageId` and `toolCallId` only have to be unique **within one turn**, so reusing `"a1"` on every turn is safe.

### Frames the platform handles

| Frame | Level | Required fields |
| - | - | - |
| `TEXT_MESSAGE_START` | Required | `messageId` |
| `TEXT_MESSAGE_CONTENT` | Required | `messageId`, `delta` |
| `TEXT_MESSAGE_END` | Required | `messageId` |
| `RUN_FINISHED` | Required | `outcome` |
| `RUN_ERROR` | Required | `message` |
| `TOOL_CALL_START` | Recommended | `toolCallId`, `toolCallName` |
| `TOOL_CALL_ARGS` | Recommended | `toolCallId`, `delta` as a **string** |
| `TOOL_CALL_END` | Recommended | `toolCallId` |
| `TOOL_CALL_RESULT` | Recommended | `toolCallId`, `content` |
| `STEP_STARTED` and `STEP_FINISHED` | Recommended | `stepName` |
| `ACTIVITY_SNAPSHOT` | Recommended | `messageId`, `activityType`, `content` |
| `ACTIVITY_DELTA` | Optional | `messageId`, `patch` |
| `STATE_SNAPSHOT` | Optional | `snapshot` |
| `STATE_DELTA` | Optional | `delta` |
| `REASONING_MESSAGE_START`, `_CONTENT`, `_END` | Optional | `messageId` |
| `CUSTOM` | Optional | `name` |

A turn has to end with `RUN_FINISHED` **or** `RUN_ERROR`.

> [!NOTE]
>
> **Do not send `RUN_STARTED`**, the platform already emits it when it opens the turn. **Do not send `REASONING_END`**, the platform emits it alongside `REASONING_MESSAGE_END`.

### Text interleaved with tool calls

Each continuous stretch of text is its own message, with its own `messageId` and its own `START` / `END` pair. A stretch closes before the next tool call, text arriving after a tool call belongs to a new message, and **the answer is the last message** of the turn.

### Ending a turn properly

* Send `RUN_FINISHED` or `RUN_ERROR` **before closing the stream.**
* Close everything you opened before the final frame: `TEXT_MESSAGE_START`, `TOOL_CALL_START`, `STEP_STARTED`, `REASONING_MESSAGE_START`. The platform does close them for you, but it **never invents a `TOOL_CALL_RESULT`**.
* Do not emit an empty bubble. When there is nothing to say, send `RUN_ERROR`.
* A tool call has to come **before** the answer that references it. The platform reads the response as a stream, so order is a real constraint.
* While you are busy, send a comment line as a heartbeat, at least every 15 seconds.

> [!WARNING]
>
> Closing the stream cleanly but forgetting the final frame is **not** treated as an error: the platform records the turn as successful with exactly what it received. An External Agent that dies mid-sentence and simply closes the socket leaves a truncated answer stored as a complete one. It looks small, but `RUN_ERROR` is the one thing that stops that happening.

## Deadlines and limits

These are defaults, not contract constants. Ask the operator if you need the real numbers for the environment you will run in.

| Deadline | Default |
| - | - |
| TCP and TLS connect | 5 seconds |
| **First frame** | **10 seconds** from the start of the response |
| Silence between chunks | 30 seconds, a comment line resets this clock |
| Whole turn | 5 minutes, set per External Agent at onboarding |
| One frame | 256 KiB |
| Whole stream | 8 MiB |
| Frames per turn | 20,000 |
| Attachment link lifetime | 7 days |

> [!WARNING]
>
> **The 10-second first-frame deadline is where nearly every integration trips.** It is not a deadline for the whole answer, it is a deadline for the **first frame**. If your architecture finishes the entire turn before emitting the first byte, for example a no-code workflow or a serverless function that buffers the response, you will time out while the External Agent is perfectly healthy.
>
> The fix: **push a frame out immediately**, before calling the model. A `STEP_STARTED` or a comment line is enough to open the stream and move you onto the far roomier 30-second idle clock.

Two more things about a turn's lifecycle:

* **There is no mid-flight cancellation.** When the user presses Stop, the platform closes the connection, and that is the entire cancel signal. If the client disconnects, stop working.
* **There is no retry.** A failed turn is a failed turn. If the user sends again, that is a new `runId`.

## Files

### Files the user uploads

Each element of `attachments[]` has `id`, `url`, `name`, `mime` and `sizeBytes`. The link is https, downloadable without auth headers, and **expires**.

* **De-duplicate on `id`**, not on `url` or `name`. The `id` stays the same across turns for the same file.
* If you need the file on a later turn, **download and store it yourself while the link is valid**. Assume the link will expire.
* The link carries no auth, so anyone holding it can download it. Do not log it, do not forward it outside your own systems.
* Do not trust the metadata: never use `name` as a path, and `mime` is declared by the platform, so verify the content yourself.

> [!NOTE]
>
> A file the platform could not produce a link for is dropped from `attachments` and named in `input` as a system notice line. That sentence comes from the platform, not from the user, so do not repeat it back verbatim.

### Files the External Agent produces

The External Agent hosts the file itself and serves a URL reachable from the user's browser, then sends it over with `STATE_SNAPSHOT` under the `artifacts` key, or with `CUSTOM` where `name` is `file`.

> [!WARNING]
>
> **One file, one channel.** Those two are independent sources and do not de-duplicate against each other. Sending both for the same file shows the user **two cards**. If your prose also embeds a markdown image pointing at it, that becomes three.

For `STATE_SNAPSHOT` and `STATE_DELTA`, only three keys are accepted: `todos`, `artifacts`, `files`. Any other key is dropped.

## Asking the user a question

When you need a decision before continuing, end the turn with `RUN_FINISHED` where `outcome.type` is `interrupt`. One stop carries **exactly one** interrupt.

| `kind` | What the user sees | Content field |
| - | - | - |
| `approval` | Approve and reject buttons | `prompt` only |
| `choice` | A question with a list of options, always plus a free-text box | `options` |
| `input` | A question with only a free-text box | `prompt` only |
| `review` | One or more proposed tool calls with viewable, editable arguments | `items` |
| `authorization` | An account connection card that opens a popup to `authorizeUrl` | `provider`, `authorizeUrl` |

```json theme={null}
{ "type": "RUN_FINISHED",
  "outcome": { "type": "interrupt",
    "interrupts": [ { "kind": "approval",
                      "prompt": "Confirm the HAN to SGN flight on 20 Aug?" } ] } }
```

On the next turn, the platform sends the decision back in `resume`:

| Interrupt `kind` | `decision` you receive | Where the content sits |
| - | - | - |
| `approval` | `approve` or `reject` | Nowhere, `input` is empty |
| `choice` and `input` | `respond` | `message`, `answers`, and `input` |
| `review` | `approve`, `reject`, `edit`, `respond` | `editedAction` when `edit` |
| `authorization` | `authorized` | Nowhere |

> [!WARNING]
>
> A `kind` outside those five values, or a `review` missing `name`, or an invalid `authorizeUrl` **fails the whole turn**. This is the only case where a malformed frame breaks the turn instead of being ignored.

`authorizeUrl` is checked on four points: it must be present, it must be https, it must not embed credentials, and the host must not be an IP address. There is no domain allowlist, so you do not have to register anything in advance.

> [!NOTE]
>
> A `decision` of `authorized` is not proof that access was granted. Check your own token store before continuing.

## `GET /health`

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

| Field | Required | Value |
| - | - | - |
| `status` | Yes | `ok`, `degraded` or `down` |
| `protocolVersions` | Yes | **Must contain `"1"`** |
| `name` | No | Display name |
| `version` | No | Your own version |

This endpoint carries the same authentication header set as `/runs`, has to be cheap, must not call a model or a database, and must not redirect either.

The platform folds the result into three states. A healthy endpoint lets chat turns through as normal. `degraded` does too, because a slow External Agent beats an External Agent taken off the air entirely. Everything else is `unavailable`, which happens when the connection fails, when it returns `401` or `403`, when the body is not JSON, when `protocolVersions` does not contain `"1"`, or when you declare `down` yourself.

> [!WARNING]
>
> A `/health` returning `ok` is **not enough to publish**. When the operator presses Test in Console, the platform also sends a real `POST /runs` turn with `input` set to `Hello` and a single-use `threadId` shaped like `validate-<16 hex characters>`, then stops at the first valid frame. That turn is not stored in history. Do not let the unusual `threadId` break your pipeline.

> [!NOTE]
>
> There is no periodic automatic health check yet. Three consecutive failures move the endpoint to `unavailable` and every chat turn is refused immediately. When the incident is over the platform **does not notice by itself**, so tell the operator to run the test again in Console.

## Error codes for log reconciliation

`code` is never shown to the user, but it is the first thing an operator reads during an incident.

| `code` | Happens when | Fixed by |
| - | - | - |
| `ENDPOINT_UNAVAILABLE` | The endpoint is marked unavailable, so the request is never sent | FPT side, re-run the test |
| `PROTOCOL_MISMATCH` | The last test saw `protocolVersions` without `"1"` | External Agent |
| `EGRESS_REFUSED` | `base_url` resolves to an address the platform will not connect to | Infrastructure |
| `CONNECT_TIMEOUT` | No handshake within 5 seconds | Infrastructure |
| `UPSTREAM_REJECTED` | The External Agent returned HTTP 3xx or 4xx for `POST /runs`, usually `401` | External Agent |
| `UPSTREAM_UNAVAILABLE` | The External Agent returned HTTP 5xx | External Agent |
| `TTFB_TIMEOUT` | The stream opened but no frame arrived within 10 seconds | External Agent |
| `IDLE_TIMEOUT` | More than 30 seconds of silence mid-stream, missing heartbeat | External Agent |
| `STREAM_TRUNCATED` | The stream broke mid-way, what arrived is still stored | Infrastructure |
| `LIMIT_EXCEEDED` | Frame size, total bytes or frame count exceeded | External Agent |
| `INTERRUPT_REJECTED` | The question card could not be built | External Agent |

A failed turn still enters history: the text that made it through is shown in the conversation with an error marker.

## What you do not have to build

| Skip | Why |
| - | - |
| `GET /tools`, `POST /credentials`, `POST /credentials/revoke` | The platform does not call them in the current version |
| Verifying an `X-FPT-Signature` | Removed from the contract |
| Logic built on `history` | Almost always absent, see Keeping your own context |
| A cancel or resume endpoint | Not in the contract |

## Checklist before onboarding

**1. Both endpoints work**

`GET /health` returns `status` as `ok` and `protocolVersions` containing `"1"`. `POST /runs` accepts the body above and returns `text/event-stream`.

**2. Authentication is complete and compared safely**

Check every header in the agreed set, compare with a constant-time function, return `401` on failure.

**3. First frame within 10 seconds**

Push a frame out before calling the model. This is the deadline most integrations miss.

**4. Context stored by threadId**

Your own store, your own TTL, surviving deploys. Test it by asking two consecutive questions that reference each other.

**5. Every turn ends properly**

`RUN_FINISHED` or `RUN_ERROR` before the stream closes, with everything you opened closed.

**6. Heartbeat**

Send a comment line at least every 15 seconds while a turn is taking a while.

**7. Idempotency on runId**

If a `runId` already completed, return the earlier result instead of running again.

> [!NOTE]
>
> When something goes wrong, send the operator four things: `runId`, `threadId`, the timestamp, and the error code from the table above if there is one. That is enough for both sides to line up their logs.


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