Skip to content

Message decode tree

Audience: Implementers of native HAPI clients (iOS / Android). This page specifies how to decode DecryptedMessage.content into renderable chat structure. This is the largest porting surface — the reference pipeline is web/src/chat/ (~4600 lines); this page is its wire-level contract. Companion pages: pagination (how messages arrive), sse (live delivery).

Source of truth: shared/src/schemas.ts (DecryptedMessageSchema), shared/src/messages.ts (envelope helpers), web/src/chat/normalize.ts, web/src/chat/normalizeUser.ts, web/src/chat/normalizeAgent.ts, web/src/chat/types.ts, hub/src/store/contentCodec.ts.


Wire shape

ts
type DecryptedMessage = {
  id: string                  // server uuid (optimistic rows: == localId until echoed)
  seq: number | null          // per-session insert counter
  localId: string | null      // client-generated id for optimistic reconciliation
  content: unknown            // the role-wrapped envelope — everything below
  createdAt: number           // hub receive time (epoch ms)
  invokedAt?: number | null   // when the agent consumed it (null = still queued)
  scheduledAt?: number | null // future-scheduled sends
  deliveryState?: 'indeterminate' // steer dispatched, outcome unproven; explicit retry/cancel required
}

content is deliberately unknown on the wire. Decoding must be total: never crash on unfamiliar content. Use stringified fallbacks for unknown envelopes, and follow the family-specific skip/validation rules below for known transport records (see Fallback rules).


Envelope

content is a role-wrapped envelope:

ts
{ role: 'user' | 'agent', content: <payload>, meta?: unknown }

Unwrap algorithm (unwrapRoleWrappedRecordEnvelope, shared/src/messages.ts) — a record qualifies when it has a string role and a content key; if content itself is not one, also probe, in order:

  1. content.message
  2. content.data.message
  3. content.payload.message

No envelope found ⇒ render the whole content as stringified agent text. role other than user/agent ⇒ stringify record.content as agent text.

meta

Opaque record; carry it through. Known keys:

KeyValuesMeaning
sentFrom'webapp', 'telegram-bot', 'cli', …Origin of a user message. 'cli' marks CLI-echo traffic: user/assistant text containing <command-name>, <command-args>, <command-message>, or <local-command-stdout> tags renders as a monospace cli-output block instead of a chat bubble, and a <command-name> block merges with its <local-command-stdout> follow-up (web/src/chat/reducerCliOutput.ts).
deliveryMode'queue' | 'steer'Durable delivery intent of a user send (see pagination). Absent = queue.

Decode tree

DecryptedMessage.content
└─ unwrap envelope → { role, content: payload, meta? }
   ├─ role: 'user'
   │   ├─ payload is string                → user text
   │   ├─ payload {type:'text', text, attachments?} → user text (+ attachments)
   │   └─ anything else                    → user text (stringified payload)
   └─ role: 'agent' — dispatch on payload.type
       ├─ 'codex'   → generic agent family        (payload.data.type dispatch)
       ├─ 'output'  → Claude SDK passthrough + agy (payload.data.type dispatch)
       ├─ 'event'   → AgentEvent union             (payload.data)
       └─ unknown   → agent text (stringified payload)

role: 'user' payloads

Reference: web/src/chat/normalizeUser.ts.

PayloadResult
bare stringuser text
{type:'text', text: string, attachments?: AttachmentMetadata[]}user text with attachments. Each attachment is accepted only when id, filename, mimeType (strings), size (number), path (string) are all present; optional previewUrl. Invalid entries are skipped, an empty result means "no attachments".
anything elseuser text = stringified payload (never drop)

role: 'agent' — family 'codex'

payload.type === 'codex' (AGENT_MESSAGE_PAYLOAD_TYPE, shared/src/modes.ts:8) is the generic agent envelope used by the non-Claude-SDK flavors (codex, gemini, cursor, copilot, grok, opencode, pi, kimi; agy uses the 'output' family below). Dispatch on payload.data.type (web/src/chat/normalizeAgent.ts):

data.typePayload fieldsRenders as
messagemessage: string, id? (stream id), streamSnapshot?Agent text. If the text is a bare JSON object with review markers (findings / overall_correctness / overall_explanation), parse it as a codex review block instead — unless it is a Pi stream snapshot (streamSnapshot: true or id matching /^pi-.+-turn-\d+-message-\d+-text-\d+$/), which is always plain text.
reasoningmessage: string, id?Reasoning (thinking) block.
errormessage: stringError event row.
tool-callcallId, name?, input?, description?, nativeTitle?/title?, nativeKind?/kind?, progress?Open a tool card keyed by callId.
tool-call-resultcallId, output, is_error?Complete the tool card with the same callId.
generated-imageimageId/image_id, fileName/file_name, mimeType/mime_type, id?, source?Inline generated image (fetch via the images REST endpoint). Missing imageId ⇒ drop.
context_compactedtrigger?, preTokens/pre_tokenscompact event row.
compact-summarysummary, tokensBefore?, estimatedTokensAfter?compact-summary event row.
token_countinfo: {last | total | …}, thread_id?, scope?Usage sample (event). Prefer info.last* over info.total*; context_tokens falls back to input_tokens; modelContextWindowcontext_window. Unparseable usage ⇒ drop.
thread_goal_updatedgoal {threadId, objective, status, tokenBudget?, tokensUsed?, timeUsedSeconds?, createdAt?, updatedAt?}, threadId?, turnId?thread-goal-updated event. statusactive|paused|budgetLimited|usageLimited|blocked|complete; invalid goal ⇒ drop.
thread_goal_clearedthreadId?thread-goal-cleared event.
planentries/items/steps: list of stepsSynthetic completed update_plan tool pair (cursor flavor). Steps accept step|content|text|title|description + status|state (normalized to pending|in_progress|completed). Empty plan ⇒ drop.
plan_updateplan/update/items/stepsSame, codex flavor.
agent-run-start / agent-run-update / agent-run-tracerun payloadBackground agent-run event rows (windowed separately, see pagination).
anything elseDrop silently (normalize.ts: unknown codex content returns null, not a stringified bubble).

Snake_case/camelCase field pairs above are both accepted — always probe both.


role: 'agent' — family 'output' (Claude SDK passthrough)

payload.data is a Claude Code SDK log entry, forwarded verbatim. Envelope-level fields on data: uuid, parentUuid, isSidechain?, parentToolUseId?, timestamp? (ISO-8601 execution-machine clock — parse to epoch ms, fall back to createdAt), and the flags below.

Skip filters — evaluate first (isSkippableAgentContent / isClaudeChatVisibleMessage):

  • data.isMeta or data.isCompactSummary truthy ⇒ hidden.
  • data.type === 'rate_limit_event' or 'tool_progress' ⇒ hidden.
  • data.type === 'system' with subtype not in {api_error, turn_duration, microcompact_boundary, compact_boundary, away_summary} ⇒ hidden.
  • Empty away_summary / empty agy_message ⇒ hidden.

Then dispatch on data.type:

assistant

data.message = {model?, content, usage?}. content is a string (⇒ one text block) or an array of blocks:

BlockFieldsRenders as
texttextagent text
thinkingthinkingreasoning
tool_useid, name, inputtool card open (description convention: input.description when present)

Other block types are ignored. message.usage carries input_tokens, output_tokens, cache_creation_input_tokens?, cache_read_input_tokens?, service_tier?, context_window?.

user

Despite the name, these arrive through the agent path (tool results and system-injected turns). data.message.content cases:

CaseRenders as
array with tool_result blocks {tool_use_id, content, is_error?, permissions?}tool card completion. Prefer entry-level data.toolUseResult over the block's content when present. permissions = {date, result:'approved'|'denied', mode?, allowedTools?, decision?} — merge into the tool card's permission state.
string content (any), or sidechain array-of-textsidechain marker {prompt} — subagent prompts and system-injected turns; group under the parent Task tool card via parentToolUseId (fallback: exact prompt match).
non-sidechain array that is entirely text blocksa real user message the CLI wrapped as output ⇒ render in the user lane.
text blocks mixed with tool resultsagent text blocks.

system subtypes → event rows

subtypeFieldsEvent
api_errorretryAttempt, maxRetries, errorapi-error
turn_durationdurationMs, messageId?turn-duration {durationMs, targetMessageId?}
microcompact_boundarymicrocompactMetadata {trigger, preTokens, tokensSaved}microcompact
compact_boundarycompactMetadata {trigger, preTokens}compact
away_summarycontent: stringrecap {text}

summary

data.summary: string ⇒ conversation-summary content block.

agy (Antigravity) — also in the 'output' family

data.typeRenders as
agy_messageAgent text (data.content, per-turn data.model?). Empty ⇒ skip. Text starting Inside the task-NNN log ⇒ compact AgyTaskLog chip (synthetic completed tool pair). Echoed raw task results ([Message] timestamp=… trailer) are stripped from the prose.
agy_tool_actionSynthetic completed tool pair (tool-call + tool-result, same id — prefer data.toolUseId, falling back to the message id). name === 'SYSTEM_MESSAGE'AgyAsyncTask background-task card; name === 'ERROR_MESSAGE'AgyError card (is_error: true); otherwise map agy tool names to canonical ones (run_command→Bash, view_file→Read, write_to_file→Write, replace_file_content→Edit, grep_search→Grep, list_dir→LS) and translate arg keys (CommandLinecommand, TargetFilefile_path, …), stripping agy's result preambles/trailers. See normalizeAgent.ts for the exact strip rules.

Unknown 'output' types

A visible data.type not matched above ⇒ stringified agent text fallback (unlike the codex family, which drops).


role: 'agent' — family 'event'

payload.data is one AgentEvent (web/src/chat/types.ts lines 17–36). The union is open-ended — the last member is {type: string} & Record<string, unknown>; tolerate unknown types (render generically or ignore, never crash).

typeFields
switchmode: 'local' | 'remote'
messagemessage: string
errormessage: string
title-changedtitle: string
limit-reachedendsAt: number, limitType: string
limit-warningutilization: number (0–1), endsAt, limitType
ready
api-errorretryAttempt, maxRetries, error: unknown
turn-durationdurationMs, targetMessageId?
microcompacttrigger, preTokens, tokensSaved
compacttrigger, preTokens
compact-summarysummary, tokensBefore?, estimatedTokensAfter?
recaptext
thread-goal-updatedgoal: ThreadGoal, threadId?, turnId?
thread-goal-clearedthreadId?
abort-restoretext
(catch-all){type: string, …}

Several event rows are also synthesized by the other two families (system subtypes, context_compacted, token-count, agent-run-*) — the renderer should treat them uniformly. Note: event-family message rows whose text is a Goal … status line are filtered by the hub at ingest and from REST pages (isRedundantGoalStatusEventContent, shared/src/messages.ts); clients need no special handling.


Fallback rules

SituationBehavior
No unwrappable envelopestringify whole content as agent text
role not user/agentstringify record.content as agent text
user payload unrecognizedstringify as user text
'codex' family, unknown data.typedrop (return nothing)
'output' family, hidden by skip filtersdrop
'output' family, visible but unknown data.typestringify as agent text
'event' family, data lacks a string typestringify as agent text

"Stringify" = a stable JSON serialization (web: safeStringify) rendered as plain text. Known event types also have the validation/empty-content skip rules listed above (for example, an image without an ID or unparseable usage). The golden fixtures and normalizer are authoritative; do not turn those transport-only records into fallback chat bubbles.


Truncation marker

At ingest the hub head+tail-truncates strings longer than 64 * 1024UTF-16 code units (String.length, not bytes) inside agent-role content (hub/src/store/contentCodec.ts): first 48 * 1024 units + \n…[hapi: truncated N chars]…\n + last 12 * 1024 units. User-role content is never truncated (it is delivered verbatim to the CLI). The operation is idempotent and applied deep (arrays/objects).

Clients must render truncated strings as-is (recognizing the …[hapi: truncated N chars]… marker is optional polish), must not assume tool results are complete, and must never choke on the marker.


Permission requests are NOT messages

Pending tool approvals never appear in the message stream. They live on the session object (shared/src/schemas.ts):

ts
session.agentState = {
  requests?:          Record<requestId, { tool: string, toolCallId?: string, arguments: unknown, createdAt?: number | null }>
  completedRequests?: Record<requestId, {
    tool, toolCallId?: string, arguments, createdAt?, completedAt?,
    status: 'canceled' | 'denied' | 'approved' | 'resolved',
    reason?, mode?, allowTools?: string[],
    decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
    answers?: Record<string, string[]>                     // flat    (AskUserQuestion)
            | Record<string, { answers: string[] }>        // nested  (request_user_input)
  }>
}

agentState updates arrive as a versioned SSE patch — apply it under the version gate described in sse.md. Render pending requests as approval cards interleaved with the chat (the web reducer keys them to the matching tool_use when one exists); on resolution the entry moves to completedRequests, whose status/answers back-fill the tool card's permission state. Decide via POST /api/sessions/:id/permissions/:requestId/approve ({mode?, allowTools?, decision?, answers?}) or …/deny ({decision?}) — see rest.md. Session-list badges come precomputed on SessionSummary.pendingRequestsCount / pendingRequests (≤ 5 entries).

resolved means native completion is known, but the winning answer/decision is not. Render it neutrally; never infer approval or fill answers from an unconfirmed local draft.

Correlate a permission with the transcript using entry.toolCallId ?? requestId, but always submit approval/denial using requestId. Claude local-mode requests use independent, one-shot reply IDs so stale responses cannot answer a later request. The same identity rule applies to synthesized cards and completed requests. A new pending reply takes precedence over an old completion for that tool; a completion for the same reply ID takes precedence over its pending entry.

In local mode, submitting a web answer removes the actionable pending entry; it need not immediately create a completion. Only Claude's native result confirms which answer won the terminal/web race. Remote-wait expiration also removes the pending entry without approving or denying the native prompt.


Golden fixtures

The golden fixtures in shared/fixtures/chat/ are the executable form of this section: input DecryptedMessage samples paired with the canonical decoded projection, generated from the web pipeline. A native decode implementation is correct when it reproduces them exactly — when the fixtures and this page disagree, the fixtures win.

Released under the AGPL-3.0 License. · Privacy Policy