Skip to main content
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.
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.
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

The two required endpoints

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

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

An ordinary chat turn is only this much:
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.
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

The envelope shared by every frame

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

Frames Console handles

A turn has to end with RUN_FINISHED or RUN_ERROR.
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.
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.
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.
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.
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.
On the next turn, Console sends the decision back in resume:
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.
A decision of authorized is not proof that access was granted. Check your own token store before continuing.

GET /health

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

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