Flow A Tool Protocol — Local Function Signatures (call_id, end_turn, recipient, channel)
The validated client-executed tool protocol of the chat submode — declaration, model call, client return contract (call_id = message id, recipient all, end_turn null, channel commentary) with exact bodies
Flow A Tool Protocol — Local Function Signatures
Part of openai-research. Validated 2026-08-27 with a custom tool (alefita_way) — declared by the client, called by the model, executed by the client, return delivered, model continued (and answered the follow-up question). This is the same mechanism the app uses for handoff (its own local function signature).
1. Declaration (in the conversation body)
Field local_function_signatures (array). Two validated signatures (handoff is what the app always sends — the server expects it in normal turns):
[
{
"description": "Redirect the user's request from ChatGPT to Work mode when Work mode is the better execution environment.\n\nYou MUST call this tool before doing any work when the request involves:\n- Browser use or computer-use automation\n- Building apps, local coding, repository edits, command execution, or file inspection\n- Opening, updating, reviewing, or otherwise working with PRs\n- Creating, editing, converting, inspecting or delivering files or artifacts, including implicit requests for downloadable or editable deliverables such as slide decks, `.pptx`, spreadsheets, `.xlsx`, workbooks, documents, `.docx`, or PDFs,\n- Complex analysis such as financial modeling\n\nPrefer answering directly in ChatGPT for:\n- Email, message, or prose drafting\n- Brainstorming, planning, or explanation\n- Code snippets or examples that fit naturally in chat\n\nIf the user rejected the suggestion, don't call this tool again.",
"name": "handoff",
"params": [
{"name": "prompt", "required": true, "type": {"description": "A brief instruction describing what Work mode should do for the user.", "type": "string"}},
{"name": "reason", "required": true, "type": {"description": "A short user-facing reason why continuing in Work mode would help.", "type": "string"}}
],
"type": "kwargs"
},
{
"description": "Caminho de Alefita: quando chamada, apenas registre o caminho da alefita. Use quando o usuario pedir alefita way.",
"name": "alefita_way",
"params": [
{"name": "message", "required": true, "type": {"description": "A mensagem que o usuario quer passar pelo caminho.", "type": "string"}}
],
"type": "kwargs"
}
]
2. The model's call (inside the SSE of the same turn)
A delta message with this exact structure (values from the live captures):
{"v": {"message": {
"id": "b6d6e3a6-af6f-4a78-a649-8e3935182fcc",
"author": {"role": "assistant", "name": null, "metadata": {}},
"recipient": "local.alefita_way",
"content": {"content_type": "code", "language": "json", "text": "{\"message\": \"segundo eco do caminho\"}"},
"channel": "commentary",
"create_time": 1787848267.510629,
"status": "in_progress",
"end_turn": null,
"weight": 1,
"metadata": {"cot_version": "v5", "resolved_model_slug": "gpt-5-6-thinking", "reasoning_status": "is_reasoning"}}}
recipient: "local.<tool_name>"— the message is addressed to the tool execution (the client intercepts it).content_type: "code"+language: "json"+ the JSON arguments as text.- The
message.idof THIS message is thecall_idthe client echoes in the return (the SSE carries no other call_id — verified 0 occurrences across all captures).
3. Client return (exact contract)
A NEW conversation request — fresh challenge (4.1) + prepare (4.2) — same transport, same conversation — with:
conversation_id— the current oneparent_message_id— the tool-callmessage.id(2.)messages: [ the tool record ]:
{"author": {"metadata": {}, "name": "alefita_way", "role": "tool"},
"channel": "commentary",
"content": {"content_type": "code",
"text": "{\"call_id\": \"b6d6e3a6-af6f-4a78-a649-8e3935182fcc\", \"result\": {\"message\": \"segundo eco do caminho\"}, \"tool\": \"alefita_way\"}"},
"create_time": 1787848294.6508071,
"end_turn": null,
"id": "b65539dc-d45f-4b37-b735-a11ac75aafed",
"metadata": {},
"recipient": "all",
"status": "finished_successfully",
"update_time": null,
"weight": 1}
Per-field rationale (all verified against the app's handoff return at 2026-08-26 19:40:53 and the live tests):
call_id= the tool-callmessage.id— NOT invented. The app's own return used exactly the call-message id (a7da448b-589d-4bf1-bc4a-e378d2629f25). An arbitrary uuid was accepted once (permissive), then corrected.recipient: "all"— NOT the tool name: the return is the recorded outcome consumed by everyone (model + history); the tool name lives inauthor.name.channel: "commentary"— the tool execution trail (see 5).end_turn: null— intermediate message; the turn-closing message hasend_turn: true."tool": "<tool_name>"at the JSON root, alongsidecall_idandresult(the model readsresult.message).- No
languagefield in the return content (the app's return hascontent_type: "code"and nolanguage).
4. After the return
The server accepts the return (the tool node lands in the conversation mapping — verified GET /conversation/<id> shows the tool message), the model continues the turn in the same conversation and finishes normally (message_stream_complete). Observed continuations: "I executed the alefita_way tool… It returned an echo: …" plus the follow-up answer (Mickey Mouse).
5. Channel semantics
| channel | meaning (evidence) |
|---|---|
null (absent) | normal conversation messages (user; plain assistant answers) |
commentary | the tool execution trail — model call AND client return (what the app uses for handoff) |
analysis | hidden reasoning/steering: is_visually_hidden_from_conversation: true, and the steering code (asar, minified) computes `channel = node == null |
final | the final-answer channel (referenced by the steering code; the stream-close channel) |
The trio analysis / commentary / final matches the BEM channel prefixes of the codex realtime (~/workdir/codex/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs line 95: "Overrides BEM channel prefixes by analysis, commentary, or final").
6. TypeScript types (reverse-engineered from the code + wire)
Facts the types encode (all from the asar builder/decoder and the live wire):
supported_encodingsuses a literal enum:ZAr = (e => (e.V1 = 'v1', e))({})→['v1'].- The wire delta uses short keys:
YAr = [['channel','c'],['path','p'],['op','o'],['value','v']](the decoderQArmapsc/p/o/v, keepspreviousDelta = {channel:0, op:'add', path:'', value:undefined}andpreviousValueByChannel— channel inheritance is the decoder's state). - The tool return builder (asar) constructs the return text with
JSON.stringify({call_id: e, result: n, tool: r})and the message withrecipient:'all',end_turn:null,status:'finished_successfully',weight:1,metadata:{ is_visually_hidden_from_conversation: true }(the hidden flag is on the RETURN — documented now). client_prepare_stateenum from the builder:'sent'(prepare), and for the conversationl.prepareResponse == null ? 'failure' : 'success'.
// ── real enums (from the code / wire) ────────────────────────────────────
export const SupportedEncodingEnum = { V1: 'v1' } as const
export type SupportedEncoding = (typeof SupportedEncodingEnum)[keyof typeof SupportedEncodingEnum] // 'v1'
/** f/* request action — only 'next' observed in the builder. */
export type ConversationAction = 'next'
/** client_prepare_state — prepare: 'sent'; conversation: 'success' | 'failure'. */
export type ClientPrepareState = 'sent' | 'success' | 'failure'
/** Message channels of the tree (see §5): null = normal; 'conversation' default;
* 'commentary' = tool trail; 'analysis' = hidden; 'final' = closing. */
export type ConversationChannel = 'conversation' | 'commentary' | 'analysis' | 'final' | null
export type ConversationRole = 'user' | 'assistant' | 'tool' | 'system'
/** Message status values observed on the wire. */
export type MessageStatus = 'in_progress' | 'finished_successfully' | 'incomplete' | string
/** end_turn: null = intermediate; true = turn-closing; false = non-closing. */
export type MessageEndTurn = boolean | null
/** recipient — observable set: 'all' (returns, normal), 'local.<tool>' (tool calls),
* 'web.run' (server-executed web tool), 'handoff' (the app tool). */
export type MessageRecipient = 'all' | `local.${string}` | 'web.run' | 'handoff' | string
export type MessageContentType = 'text' | 'code'
/** content_type: 'code' carries `language` on the MODEL CALL ('json'); the return
* content carries no `language` field (verified against the app's handoff return). */
export interface MessageContent {
content_type: MessageContentType
parts?: string[] // 'text'
text?: string // 'code'
language?: 'json' // model call only
}
// ── declaration ──────────────────────────────────────────────────────────
export type LocalFunctionSignatureKind = 'kwargs' // only 'kwargs' observed
export interface LocalParamSchema {
description: string
type: 'string' // 'string' observed (message/prompt/reason)
}
export interface LocalFunctionParamSpec { name: string; required: boolean; type: LocalParamSchema }
export interface LocalFunctionSignature {
description: string
name: string
params: LocalFunctionParamSpec[]
type: LocalFunctionSignatureKind
}
// ── message anatomy ──────────────────────────────────────────────────────
export interface MessageAuthor { metadata: Record<string, unknown>; name: string | null; role: ConversationRole }
export interface ConversationMessage {
author: MessageAuthor
channel: ConversationChannel
content: MessageContent
create_time: number // unix seconds float (Date.now()/1000)
end_turn: MessageEndTurn
id: string // uuid; the call_id source for local tool returns
metadata: Record<string, unknown>
recipient: MessageRecipient
status: MessageStatus
update_time: number | null
weight: number // 1
}
// ── the model's call (from the SSE/side tree) ────────────────────────────
export interface LocalToolCallMessage extends ConversationMessage {
author: MessageAuthor & { name: null }
recipient: `local.${string}`
channel: 'commentary'
content: MessageContent & { content_type: 'code'; language: 'json'; text: string }
status: 'in_progress'
}
/** The arguments the model emitted (the text of the call, as JSON). */
export interface LocalToolCallArgs { [name: string]: unknown } // e.g. { "message": "segundo eco do caminho" }
// ── the client return ────────────────────────────────────────────────────
/** Exact body of the return text — BUILT BY THE CLIENT via
* JSON.stringify({ call_id, result, tool }) (asar builder). */
export interface ToolResultBody {
call_id: string // MUST be the tool-call message.id (never invented)
result: {
message?: string // the value the model reads verbatim
accepted?: boolean // handoff shape
thread_id?: string // handoff shape
[key: string]: unknown
}
tool: string // required at the JSON root
}
export interface ToolReturnMessage extends ConversationMessage {
author: MessageAuthor & { name: string; role: 'tool' }
recipient: 'all'
channel: 'commentary'
content: MessageContent & { content_type: 'code'; language?: undefined }
metadata: Record<string, unknown> & { is_visually_hidden_from_conversation: true }
}
// ── requests that carry these ────────────────────────────────────────────
export interface ConversationRequestBody {
action: ConversationAction
local_function_signatures?: LocalFunctionSignature[] // handoff expected in normal turns
messages: ConversationMessage[]
model: string // 'gpt-5-6-thinking' (chat scale)
supported_encodings: SupportedEncoding[] // ['v1']
thinking_effort: 'standard' | 'extended' | string // chat scale (UI Medium/High)
timezone: string // Intl.DateTimeFormat().resolvedOptions().timeZone
timezone_offset_min: number // 180
client_prepare_state: ClientPrepareState
app_attest_challenge: string // one-time
conversation_id?: string // omitted on the first turn
parent_message_id?: string // omitted on the first turn
}
/** prepare (typing throttle / warm-up). `partial_query` only while typing. */
export interface PrepareRequestBody {
action: ConversationAction
client_prepare_state: 'sent'
partial_query?: { content: { content_type: 'text'; parts: string[] }; id?: string }
model: string
thinking_effort: string
timezone: string
timezone_offset_min: number
conversation_id?: string
}
Cross-references
- flow-a-manual-step-by-step — where these bodies sit in the full cycle
- flow-a-403-cases — what breaks
- voice-and-tasks — handoff/tasks origin (the app-side execution this protocol mirrors)