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 onePOST /runs call per turn. Everything else belongs to the partner.
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
301or302counts as an error. To move, changebase_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_digestorcrypto.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
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.
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
ReturnContent-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 ownmessageId 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_FINISHEDorRUN_ERRORbefore 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 aTOOL_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.
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.
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 ofattachments[] has id, url, name, mime and sizeBytes. The link is https, downloadable without auth headers, and expires.
- De-duplicate on
id, not onurlorname. Theidstays 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
nameas a path, andmimeis 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 withSTATE_SNAPSHOT under the artifacts key, or with CUSTOM where name is file.
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 withRUN_FINISHED where outcome.type is interrupt. One stop carries exactly one interrupt.
resume:
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.
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.