Codex CLI renders hook additionalContext as visible developer message

Open 💬 16 comments Opened Apr 6, 2026 by FasterPHP
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

Codex CLI is rendering hook hookSpecificOutput.additionalContext as a visible developer message in the session transcript.

This appears to contradict the documented behavior used by the Hindsight Codex integration, which says auto-recall should be "invisible to the transcript, visible to Codex".

Environment

  • Codex CLI version: 0.118.0
  • Platform: Linux
  • Hook integration: Hindsight Codex integration

Expected behavior

When a UserPromptSubmit hook returns:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "..."
  }
}

the additionalContext should be available to the model without being shown in the user-visible transcript.

Actual behavior

The additionalContext payload is inserted into the session as a visible developer message containing the full Hindsight memory block.

In the same setup, Codex also shows visible UI status like:

• Running Stop hook

The main bug here is the visible transcript rendering of additionalContext.

Reproduction

  1. Enable Codex hooks.
  2. Configure a UserPromptSubmit hook that returns hookSpecificOutput.additionalContext.
  3. Use the Hindsight Codex integration, or any minimal hook that returns hidden context this way.
  4. Start a session and submit a prompt.
  5. Observe that the hook context is shown in the transcript as a visible developer message.

Relevant docs

Hindsight integration docs state that auto-recall is:

  • "invisible to the transcript"
  • "visible to Codex"

Reference:

Local evidence

The Hindsight hook returns additionalContext here:

output = {
    "hookSpecificOutput": {
        "hookEventName": "UserPromptSubmit",
        "additionalContext": context_message,
    }
}
json.dump(output, sys.stdout)

And the resulting session log contains it as a visible developer message:

{"type":"response_item","payload":{"type":"message","role":"developer", ... "<hindsight_memories> ... </hindsight_memories>"}}

Notes

This may be:

  • a Codex CLI regression in how hook additionalContext is rendered, or
  • a mismatch between current Codex behavior and the documented hook contract relied on by integrations.

View original on GitHub ↗

16 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16486

Powered by Codex Action

Nickonomic · 2 months ago

Adding another concrete use case here.

I rely on UserPromptSubmit.additionalContext to inject strong behavioral guidance on every turn. Behaviorally, this works well. The problem is almost entirely presentation:

  • Codex renders the full hook-provided context into the visible transcript every turn
  • that crowds the terminal window and makes live sessions harder to read
  • it creates pressure to shorten or externalize otherwise-good hook contracts purely to reduce UI noise

So from this side, the issue is not "hooks are too verbose". The issue is that Codex currently makes hook context too visible.

Ideal behavior would be one of:

  1. keep additionalContext model-visible but transcript-invisible
  2. or show a collapsed one-line marker like UserPromptSubmit hook applied
  3. or make hook visibility configurable, for example hidden | collapsed | full
  4. optionally suppress repeated identical hook context and show only a small marker/diff

If Codex hid or collapsed this automatically, the current hook architecture would be fine. The operational behavior is already good; the transcript rendering is the thing pushing users toward worse designs.

NicholaiVogel · 2 months ago

I dug into this locally and I think the root cause is pretty narrow.

hookSpecificOutput.additionalContext is correctly being turned into model-visible developer context, but it currently goes through record_conversation_items in codex-rs/core/src/hook_runtime.rs. That path emits/persists a raw transcript item, which is why integrations like Hindsight end up showing the full memory block as a visible developer message.

The behavior I think would solve this cleanly is:

  1. Keep additionalContext model-visible.
  2. Record it into in-memory history only, not the durable transcript/event stream.
  3. Make the hook output visibility configurable on the command hook itself, for example:
additionalContextVisibility = "collapsed" # hidden | collapsed | full

Where:

  • hidden injects the context for the model but renders no hook output entry.
  • collapsed injects the context and renders a small marker like additional context applied.
  • full preserves the current visible/full-context behavior for users who want that.

I have a local patch along those lines with regression coverage. It changes the additional-context recording path to use in-memory history instead of record_conversation_items, adds the per-hook visibility enum/config, updates generated config/app-server schemas, and covers the hidden/collapsed/full behavior in hook tests.

Validation run locally:

just fmt
just write-config-schema
just write-app-server-schema
cargo test -p codex-config
cargo test -p codex-hooks
cargo test -p codex-core hook_runtime::tests::record_additional_contexts_stays_out_of_transcript_events
cargo test -p codex-app-server-protocol
cargo test -p codex-app-server config_api::tests::map_requirements_toml_to_api_converts_core_enums
just fix -p codex-config
just fix -p codex-hooks
just fix -p codex-core
just fix -p codex-app-server-protocol
just fix -p codex-app-server
git diff --check

Happy to open a PR if this approach matches the intended hook contract.

aaf2tbz · 2 months ago

Thanks for the detailed discussion here — I dug into the Codex path and I believe there are two separable concerns:

  1. the minimal correctness fix, and
  2. an optional UI/config visibility behavior.

The narrow bug appears to be in codex-rs/core/src/hook_runtime.rs, where hookSpecificOutput.additionalContext is converted into developer-role ResponseItems and then passed through record_conversation_items.

That helper currently:

  1. appends items to in-memory conversation history,
  2. persists them into rollout/session-log state,
  3. emits raw response-item events for app-server/v2 clients.

For normal conversation items, that is correct. For hook additionalContext, this seems too broad.

If the contract is “model-visible, transcript-invisible,” model visibility is already satisfied by adding these items to in-memory history. The persistence/event emission side effects are what make this content appear in transcript/session-log surfaces.

Minimal fix

Update record_additional_contexts to record only into in-memory history:

sess.record_into_history(developer_messages.as_slice(), turn_context)
    .await;

instead of:

sess.record_conversation_items(turn_context, developer_messages.as_slice())
    .await;

(Important detail: parameter order differs between these APIs.)

This preserves the important behavior:

  • model still receives hook-provided additionalContext
  • no rollout/session-log persistence for hidden context
  • no RawResponseItem emission for app-server/v2 transcript rendering
  • existing developer-role conversion can remain unchanged

Regression test shape

A good regression test would assert:

  • additional context is present in the in-memory conversation state used for the next model request,
  • absent from rollout/session persistence,
  • absent from raw response-item notifications.

Optional follow-up (UX/config)

A configurable rendering mode could still be useful, e.g.:

additionalContextVisibility = "collapsed" # hidden | collapsed | full

Where:

  • hidden: inject for model, render nothing
  • collapsed: inject for model, render a small marker (e.g. “additional context applied”)
  • full: preserve current fully visible behavior

But I’d treat this as a follow-up UX/config layer. The core issue is that additionalContext currently flows through the same recording path as transcript-visible conversation items.

mychmly · 2 months ago

Adding one more concrete large-context use case and validation point.

Environment:

  • Codex CLI: codex-cli 0.128.0
  • Platform: Darwin 25.4.0 arm64 arm
  • Terminal: Ghostty 1.3.1
  • Surface: standalone Codex CLI with UserPromptSubmit hooks

Use case:

A project-level agent harness needs to inject a large session-start context block into the model. The payload is not ordinary chat content; it is source/context material such as:

  • project documentation selected by a read plan
  • source-pack style context
  • read receipts / audit metadata
  • startup context needed for the model to behave correctly in a new session

The desired UX is:

  • the model receives the full context
  • the user sees only a compact audit/status report
  • the CLI transcript is not flooded by hundreds of KB of hook context

Current behavior:

UserPromptSubmit.hookSpecificOutput.additionalContext is both model-visible and transcript-visible. In a real harness test, a new-session hook returned a compact report plus a source-pack-like context payload. Codex accepted the hook output, but the entire payload was rendered into the visible CLI transcript as hook/developer context.

Observed result from a sanitized local test:

context_bytes: ~327 KB
contains visible source-pack marker: true
contains full hook-provided context: true

If the harness suppresses the large additionalContext to avoid flooding the CLI, the model no longer receives the full session-start context. That creates a hard tradeoff:

full model context + fast startup + transcript flood
clean transcript + fast startup + incomplete model context
clean transcript + complete model context + slow/manual fallback reads

This is exactly the kind of case where a model-visible but hidden/collapsed hook context channel is needed.

Minimal reproduction shape:

// .codex/hooks.json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 /tmp/codex_large_context_hook.py"
          }
        ]
      }
    ]
  }
}
# /tmp/codex_large_context_hook.py
import json

compact_audit = "Hook applied: large model context injected (synthetic test)."
large_model_context = "\n".join([
    "--- synthetic-source-pack ---",
    "x" * 200_000,
])

print(json.dumps({
    "hookSpecificOutput": {
        "hookEventName": "UserPromptSubmit",
        "additionalContext": compact_audit + "\n\n" + large_model_context,
    }
}))

Expected behavior:

Codex should provide a way for hooks to send context that is model-visible without rendering the entire payload into the visible transcript.

Potential API/implementation directions:

  1. Add a visibility mode for hook additionalContext, for example:
additionalContextVisibility = "collapsed" # hidden | collapsed | full

Semantics:

  • hidden: model-visible, no transcript rendering
  • collapsed: model-visible, transcript shows only a small marker/summary
  • full: current behavior, render the whole payload
  1. Alternatively, split model context from user display context:
{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "<large model-visible context>",
    "displayContext": "Hook applied: 200 KB model context injected"
  }
}
  1. Minimal internal fix if the intended contract is already “model-visible, transcript-invisible”:

Record hook-provided additionalContext into the in-memory model history used for the next request, but do not persist/emit it through the same transcript/session-log/raw-response-item path as normal visible conversation items unless visibility is explicitly full.

This would preserve the important behavior:

  • the model still receives additionalContext
  • the terminal UI remains readable
  • integrations do not have to choose between correctness and usability
  • users who want full hook auditability can opt into full

This issue is not about hiding tool execution from users. A compact marker or audit line is still useful. The problem is that the full model-context payload is currently forced into the human-facing transcript.

oxysoft · 2 months ago

Indexed this hook ticket in the umbrella tracker: #21753

Goal: collect the scattered Codex hook requests and bugs into one parity matrix for Full Claude Code Hook Parity (29+), while preserving this issue as the detailed thread for its specific behavior.

rsaulo · 2 months ago

I prepared a small patch for this in a fork because direct PR creation against openai/codex is currently blocked for my account (CreatePullRequest permission denied via GraphQL; REST returned 404).

Patch/branch:

Approach:

  • keep hook additionalContext model-visible by recording it into in-memory conversation history
  • stop persisting/emitting it as a raw transcript item
  • replace the human-facing HookCompleted context entry text with a compact byte-count marker, so the full payload no longer floods the TUI/transcript

Validated locally with:

  • PATH="$HOME/.cargo/bin:$PATH" cargo test -p codex-core hook_runtime::tests
  • PATH="$HOME/.cargo/bin:$PATH" cargo test -p codex-hooks

This is intended to address the core issue here while also aligning with #21696 / #20766.

Keesan12 · 2 months ago

The dangerous part is not only that ^GdditionalContext becomes visible, it is that the receipt boundary collapses. Hidden runtime context and visible transcript context serve different jobs, and once they merge, integrations can no longer rely on either privacy or clean postmortems. I would want Codex to keep a separate provenance tag for hook-provided hidden context so the model can consume it while the operator can still audit that it existed without seeing it rendered as a normal developer message.

hangaoke1 · 1 month ago

mark

Horacehxw · 1 month ago

I can reproduce this same class of issue with a Superpowers SessionStart hook setup.

Superpowers returns hookSpecificOutput.additionalContext so the model receives startup instructions. Today Codex surfaces that context through two separate visible paths:

  1. HookCompleted entries as HookOutputEntryKind::Context, which the TUI renders as hook context: ...
  2. persisted/raw response items because record_additional_contexts currently uses record_conversation_items, making the context transcript-visible

I prepared a focused patch here:

The approach is intentionally narrower than a fold/collapsed UI feature:

  • keep hook additionalContext model-visible
  • record it into in-memory model history only, not rollout/raw transcript output
  • honor suppressOutput: true for SessionStart, SubagentStart, and UserPromptSubmit context hook entries
  • keep stop/block/error feedback visible
  • leave PreToolUse / PostToolUse suppressOutput behavior unchanged for now

A direct PR against openai/codex is currently blocked for my account with CreatePullRequest permission denied, so I pushed the patch to a fork and linked the compare above.

Maintainer questions:

  1. Is the intended contract for additionalContext model-visible but transcript/raw-response-hidden?
  2. Is it acceptable to support suppressOutput first for SessionStart / SubagentStart / UserPromptSubmit, while leaving the currently unsupported tool-hook cases alone?
  3. Should the default HookCompleted context entry stay fully visible unless suppressOutput: true, or would maintainers prefer hidden-by-default for all additionalContext?
zycaskevin · 1 month ago

Thanks for documenting this. I took a quick look and wanted to share a possible direction, in case it helps.

It looks like the core issue may be that hook additionalContext is currently flowing through the same path that records conversation items and emits raw response items for the visible transcript.

One possible fix direction could be to keep those hook-provided developer messages available to the model, but avoid emitting them through the raw response item path that the visible transcript consumes.

Concretely, that would mean:

  • keep additionalContext in the model-visible conversation history
  • avoid rendering those hook-injected messages as visible transcript items
  • add a regression test confirming that hook context updates history without producing visible raw item events

I tried a small local patch in that direction and the focused hook runtime tests passed locally:

  • cargo fmt --check
  • cargo test -p codex-core hook_runtime::tests --lib

Patch reference, only as a discussion aid:
https://github.com/openai/codex/compare/main...zycaskevin:fix/hook-additional-context-transcript-visibility

attila · 1 month ago

I hit the same blocker while porting lore's Claude Code integration to Codex.

Context: lore injects deterministic coding conventions through hook
hookSpecificOutput.additionalContext. On Claude Code this is model-visible but not displayed in the
operator transcript, which is essential: the injected payload can be full pattern files/pages of
project conventions.

I built a local Codex plugin/adapter and verified:

  • Codex local plugin install works
  • bundled skills load
  • bundled MCP config loads
  • hooks fire
  • SessionStart, PreToolUse, UserPromptSubmit, and PostToolUse fixtures are accepted
  • additionalContext reaches the model

But the UX is not shippable because Codex renders the injected context visibly in the terminal. This means pages of hook-injected convention text are interleaved with normal agent output, making it hard for the operator to distinguish injected context from conversation.

For this integration, summarizing/truncating is not a viable workaround. The product requirement is full-fidelity, deterministic, model-visible context that is hidden from the operator transcript, as Claude Code provides.

Public branch with the experimental integration and blocker docs: https://github.com/attila/lore/tree/feat/codex-plugin

The requested capability is either:

  1. Do not render hookSpecificOutput.additionalContext in the visible TUI transcript, while still making it model-visible; or
  2. Add an explicit hook output/display control for model-only hidden context.

Without that, full-body hook context integrations are effectively blocked on Codex CLI.

cowwoc · 1 month ago

Possible strong relation to #28736.

That bug reproduces SessionStart hooks for source=compact firing on later turns when no new compaction actually took place, due to duplicate compact events being drained later. If a hook injects additionalContext, that can cause extra unexpected developer-message/context pollution on a later turn, which looks directly relevant to visible reinjection symptom here.

markrogersjr · 14 days ago

I prepared a focused patch against current main at 8268cbfb0e5f39cb4efff928264fe8f29ddacafb.

The root cause is that record_additional_contexts currently calls record_conversation_items, which does three things at once: updates model history, persists the item to the rollout, and emits RawResponseItem. Hook context needs only the first behavior.

This patch:

  • keeps hook additionalContext in the in-memory history sent to the model;
  • stops persisting/emitting the full developer message as a transcript item;
  • honors suppressOutput: true for the UserPromptSubmit context entry, while keeping stop/block/error feedback visible;
  • adds regression coverage for model visibility, transcript-event silence, and suppressOutput;
  • makes no config or schema changes.

With the patch, a hook can request completely silent context injection with:

{
  "suppressOutput": true,
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "model-visible context"
  }
}

<details>
<summary>Patch</summary>

diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs
index 342e4d4..a42169d 100644
--- a/codex-rs/core/src/hook_runtime.rs
+++ b/codex-rs/core/src/hook_runtime.rs
@@ -602,7 +602,7 @@ pub(crate) async fn record_additional_contexts(
         return;
     }
 
-    sess.record_conversation_items(turn_context, developer_messages.as_slice())
+    sess.record_model_context_items(turn_context, developer_messages.as_slice())
         .await;
 }
 
@@ -788,7 +788,9 @@ mod tests {
     use super::additional_context_messages;
     use super::hook_run_analytics_payload;
     use super::hook_run_metric_tags;
+    use super::record_additional_contexts;
     use crate::session::tests::make_session_and_context;
+    use crate::session::tests::make_session_and_context_with_rx;
     use codex_protocol::protocol::HookCompletedEvent;
     use codex_protocol::protocol::HookRunSummary;
     use codex_utils_absolute_path::test_support::PathBufExt;
@@ -828,6 +830,36 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn record_additional_contexts_stays_out_of_transcript_events() {
+        let (session, turn_context, events) = make_session_and_context_with_rx().await;
+        let additional_context = "transcript-hidden reef";
+
+        record_additional_contexts(
+            &session,
+            &turn_context,
+            vec![additional_context.to_string()],
+        )
+        .await;
+
+        let history = session.clone_history().await;
+        let [codex_protocol::models::ResponseItem::Message { role, content, .. }] =
+            history.raw_items()
+        else {
+            panic!("expected one model-visible developer message");
+        };
+        assert_eq!(
+            (role.as_str(), content.as_slice()),
+            (
+                "developer",
+                &[ContentItem::InputText {
+                    text: additional_context.to_string(),
+                }][..],
+            )
+        );
+        assert!(events.try_recv().is_err());
+    }
+
     #[tokio::test]
     async fn hook_run_analytics_payload_uses_completed_turn_id() {
         let (_session, turn_context) = make_session_and_context().await;
diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs
index 560dcf5..c140764 100644
--- a/codex-rs/core/src/session/mod.rs
+++ b/codex-rs/core/src/session/mod.rs
@@ -2711,8 +2711,7 @@ impl Session {
         }
     }
 
-    /// Records conversation items: append to history, persist to rollout, and
-    /// notify clients observing raw response items.
+    /// Normalizes conversation items before adding them to history.
     pub(crate) fn prepare_conversation_items_for_history<'a>(
         &self,
         turn_context: &TurnContext,
@@ -2773,6 +2772,31 @@ impl Session {
         ))
     }
 
+    async fn record_prepared_items_in_history(
+        &self,
+        turn_context: &TurnContext,
+        items: &[ResponseItem],
+    ) {
+        let mut state = self.state.lock().await;
+        state.current_time_reminder.note_recorded_items(items);
+        state.record_items(
+            items.iter(),
+            turn_context.model_info.truncation_policy.into(),
+        );
+    }
+
+    /// Records model-only context without persisting it or notifying clients.
+    #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))]
+    pub(crate) async fn record_model_context_items(
+        &self,
+        turn_context: &TurnContext,
+        items: &[ResponseItem],
+    ) {
+        let items = self.prepare_conversation_items_for_history(turn_context, items);
+        self.record_prepared_items_in_history(turn_context, items.as_ref())
+            .await;
+    }
+
     #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))]
     pub(crate) async fn record_conversation_items(
         &self,
@@ -2781,14 +2805,8 @@ impl Session {
     ) {
         let items = self.prepare_conversation_items_for_history(turn_context, items);
         let items = items.as_ref();
-        {
-            let mut state = self.state.lock().await;
-            state.current_time_reminder.note_recorded_items(items);
-            state.record_items(
-                items.iter(),
-                turn_context.model_info.truncation_policy.into(),
-            );
-        }
+        self.record_prepared_items_in_history(turn_context, items)
+            .await;
         self.persist_rollout_response_items(items).await;
         self.send_raw_response_items(turn_context, items).await;
     }
diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs
index 2934bd3..b9609a9 100644
--- a/codex-rs/hooks/src/events/user_prompt_submit.rs
+++ b/codex-rs/hooks/src/events/user_prompt_submit.rs
@@ -156,6 +156,7 @@ fn parse_completed(
                 } else if let Some(parsed) =
                     output_parser::parse_user_prompt_submit(&run_result.stdout)
                 {
+                    let suppress_output = parsed.universal.suppress_output;
                     if let Some(system_message) = parsed.universal.system_message {
                         entries.push(HookOutputEntry {
                             kind: HookOutputEntryKind::Warning,
@@ -165,13 +166,16 @@ fn parse_completed(
                     if parsed.invalid_block_reason.is_none()
                         && let Some(additional_context) = parsed.additional_context
                     {
-                        common::append_additional_context(
-                            &mut entries,
-                            &mut additional_contexts_for_model,
-                            additional_context,
-                        );
+                        if suppress_output {
+                            additional_contexts_for_model.push(additional_context);
+                        } else {
+                            common::append_additional_context(
+                                &mut entries,
+                                &mut additional_contexts_for_model,
+                                additional_context,
+                            );
+                        }
                     }
-                    let _ = parsed.universal.suppress_output;
                     if !parsed.universal.continue_processing {
                         status = HookRunStatus::Stopped;
                         should_stop = true;
@@ -288,6 +292,30 @@ mod tests {
     use crate::engine::ConfiguredHandler;
     use crate::engine::command_runner::CommandRunResult;
 
+    #[test]
+    fn suppress_output_hides_context_entry() {
+        let parsed = parse_completed(
+            &handler(),
+            run_result(
+                Some(0),
+                r#"{"suppressOutput":true,"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"hidden prompt context"}}"#,
+                "",
+            ),
+            Some("turn-1".to_string()),
+        );
+
+        assert_eq!(
+            parsed.data,
+            UserPromptSubmitHandlerData {
+                should_stop: false,
+                stop_reason: None,
+                additional_contexts_for_model: vec!["hidden prompt context".to_string()],
+            }
+        );
+        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
+        assert_eq!(parsed.completed.run.entries, Vec::new());
+    }
+
     #[test]
     fn continue_false_preserves_context_for_later_turns() {
         let parsed = parse_completed(

</details>

Red/green validation:

  • Before the production change, suppress_output_hides_context_entry failed because a full Context entry was emitted.
  • Before the production change, record_additional_contexts_stays_out_of_transcript_events failed because events.try_recv() received a raw response item.
  • just test -p codex-hooks: 121 passed.
  • just test -p codex-core hook_runtime::tests: 6 passed.
  • just fmt: passed.
  • git diff --check: passed.

I did not run the complete workspace test suite; the results above are the focused affected-crate suites. I have not opened an upstream PR because docs/contributing.md says external PRs are invitation-only. If a maintainer confirms this direction, I can package the same patch as an invited PR.

cowwoc · 12 days ago

This seems to be fixed for me in v0.143.0. The output still renders to the user but using … +1044 lines (ctrl + t to view transcript) to hide long output.

attila · 7 days ago

For anyone tracking this: the two upstream attempts at a fix (the UserInstructions stack #30138–#30141 and the async hook stack #31885–#31887) were both closed unmerged in the week of 2026-07-06, and the community patch above is still awaiting review.

I've asked for maintainer direction on the intended design over in #21696, since that's where the design options are laid out. This bug and that feature request resolve the same underlying coupling of model visibility and TUI rendering.