WikifitaGitHub live67e8de5
pesquisa · openai-research/flow-a-tool-protocol

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

Baixar raw

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.id of THIS message is the call_id the 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 one
  • parent_message_idthe tool-call message.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-call message.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 in author.name.
  • channel: "commentary" — the tool execution trail (see 5).
  • end_turn: null — intermediate message; the turn-closing message has end_turn: true.
  • "tool": "<tool_name>" at the JSON root, alongside call_id and result (the model reads result.message).
  • No language field in the return content (the app's return has content_type: "code" and no language).

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

channelmeaning (evidence)
null (absent)normal conversation messages (user; plain assistant answers)
commentarythe tool execution trail — model call AND client return (what the app uses for handoff)
analysishidden reasoning/steering: is_visually_hidden_from_conversation: true, and the steering code (asar, minified) computes `channel = node == null
finalthe 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_encodings uses 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 decoder QAr maps c/p/o/v, keeps previousDelta = {channel:0, op:'add', path:'', value:undefined} and previousValueByChannel — 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 with recipient:'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_state enum from the builder: 'sent' (prepare), and for the conversation l.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