> ## Documentation Index
> Fetch the complete documentation index at: https://docs-agents.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.

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

## Who owns what

Console does not run the External Agent. Console publishes the Digital Worker, provides the chat interface, stores conversation history, and makes one `POST /runs` call per turn. Everything else belongs to the partner.

| Responsibility | Owner |
| - | - |
| Chat interface, conversation history, publishing | Console |
| Calling `POST /runs` per turn, rendering the frames it receives | Console |
| 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 Digital Worker produces and serving a downloadable link | External Agent |

> [!WARNING]
>
> Console **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-62e894b4/3vi0Hyj1ddtKI8oH/images/ext_run_en.png?fit=max\&auto=format\&n=3vi0Hyj1ddtKI8oH\&q=85\&s=55e0dea9b17a172627a25371200d3317)

## 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`, Console calls `POST https://agent.example.com/fpt/runs` and `GET https://agent.example.com/fpt/health`.

Constraints on `base_url`:

* **https** is required. Console rejects `http`.
* Credentials must not be embedded in the URL as `user:pass@`.
* Console 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

Console sends a set of headers agreed at onboarding, identical on both endpoints. The partner decides what that set contains: Console 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`, Console does not prepend a prefix.
* **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: Console 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 Console 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 Digital Worker is being called. One `base_url` can serve many Digital Workers |
| `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 Digital Worker 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, Console 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]
>
> A Digital Worker 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. Console 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.

Console'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 Console 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`**, Console already emits it when it opens the turn. **Do not send `REASONING_END`**, Console 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`. Console 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. Console 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: Console records the turn as successful with exactly what it received. A Digital Worker that dies mid-sentence and simply closes the socket leaves a truncated answer stored as a complete one. That is why `RUN_ERROR` matters more than it looks.

## 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 Digital Worker 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 Digital Worker 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, Console 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 Console, so verify the content yourself.

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

### Files the Digital Worker 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, Console 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.

Console folds the result into three states. `degraded` still lets chat turns through, because a slow Digital Worker beats a Digital Worker pulled off the air. 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, Console 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.

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