# NousResearch/hermes-agent · API session persistence and cross-client visibility

## Open now

- Nothing open on GitHub.

These items address persisting chat sessions created via the /v1/chat/completions API endpoint and surfacing them consistently across clients (WebUI and CLI), including session ID headers and shared session storage.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #4478. Verify it addresses your case.
- **Recommended:** read PR #4478 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-7127`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.34`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #3438: \[Feature\]: Persist /v1/chat/completions sessions so Open WebUI chats appear in \`hermes sessions list\`
- #58518: WebUI resume list can hide active CLI sessions before final output

## Prior & competing PRs

- **#4478: feat(api-server): add X-Hermes-Session-Id header for session continuity**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/api_server.py`, `tests/gateway/test_api_server.py`
  - fix hunks:
  - `gateway/platforms/api_server.py`

    ```diff
    @@ -2,7 +2,7 @@
     OpenAI-compatible API server platform adapter.
     
     Exposes an HTTP server with endpoints:
    -- POST /v1/chat/completions        — OpenAI Chat Completions format (stateless)
    +- POST /v1/chat/completions        — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
     - POST /v1/responses               — OpenAI Responses API format (stateful via previous_response_id)
     - GET  /v1/responses/{response_id} — Retrieve a stored response
     - DELETE /v1/responses/{response_id} — Delete a stored response
    ```

    ```diff
    @@ -300,6 +300,7 @@ def __init__(self, config: PlatformConfig):
             self._runner: Optional["web.AppRunner"] = None
             self._site: Optional["web.TCPSite"] = None
             self._response_store = ResponseStore()
    +        self._session_db: Optional[Any] = None  # Lazy-init SessionDB for session continuity
     
         @staticmethod
         def _parse_cors_origins(value: Any) -> tuple[str, ...]:
    ```

    ```diff
    @@ -496,7 +497,23 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
                     status=400,
                 )
     
    -        session_id = str(uuid.uuid4())
    +        # Allow caller to continue an existing session by passing X-Hermes-Session-Id.
    +        # When provided, history is loaded from state.db instead of from the request body.
    +        provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
    +        if provided_session_id:
    +            session_id = provided_session_id
    +            try:
    +                if self._session_db is None:
    +                    from hermes_state import SessionDB
    +                    self._session_db = SessionDB()
    +                history = self._session_db.get_messages_as_conversation(session_id)
    +            except Exception as e:
    +                logger.warning("Failed to load session history for %s: %s", session_id, e)
    +                history = []
    +        else:
    +            session_id = str(uuid.uuid4())
    +            # history already set from request body above
    +
             completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
             model_name = body.get("model", "hermes-agent")
             created = int(time.time())
    ```

    ```diff
    @@ -540,7 +557,7 @@ def _on_tool_progress(name, preview, args):
     
                 return await self._write_sse_chat_completion(
                     request, completion_id, model_name, created, _stream_q,
    -                agent_task, agent_ref,
    +                agent_task, agent_ref, session_id=session_id,
                 )
     
             # Non-streaming: run the agent (with optional Idempotency-Key)
    ```

    ```diff
    @@ -599,11 +616,11 @@ async def _compute_completion():
                 },
             }
     
    -        return web.json_response(response_data)
    +        return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id})
     
         async def _write_sse_chat_completion(
             self, request: "web.Request", completion_id: str, model: str,
    -        created: int, stream_q, agent_task, agent_ref=None,
    +        created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
         ) -> "web.StreamResponse":
             """Write real streaming SSE from agent's stream_delta_callback queue.
     
    ```

    ```diff
    @@ -620,6 +637,8 @@ async def _write_sse_chat_completion(
             cors = self._cors_headers_for_origin(origin) if origin else None
             if cors:
                 sse_headers.update(cors)
    +        if session_id:
    +            sse_headers["X-Hermes-Session-Id"] = session_id
             response = web.StreamResponse(status=200, headers=sse_headers)
             await response.prepare(request)
     
    ```
  - `tests/gateway/test_api_server.py`

    ```diff
    @@ -1576,3 +1576,110 @@ async def test_conversation_store_false_no_mapping(self, adapter):
                     assert resp.status == 200
                     # Conversation mapping should NOT be set since store=false
                     assert adapter._response_store.get_conversation("ephemeral-chat") is None
    +
    +
    +# ---------------------------------------------------------------------------
    +# X-Hermes-Session-Id header (session continuity)
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestSessionIdHeader:
    +    @pytest.mark.asyncio
    +    async def test_new_session_response_includes_session_id_header(self, adapter):
    +        """Without X-Hermes-Session-Id, a new session is created and returned in the header."""
    +        mock_result = {"final_response": "Hello!", "messages": [], "api_calls": 1}
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
    +                mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
    +                resp = await cli.post(
    +                    "/v1/chat/completions",
    +                    json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]},
    +                )
    +            assert resp.status == 200
    +            assert resp.headers.get("X-Hermes-Session-Id") is not None
    +
    +    @pytest.mark.asyncio
    +    async def test_provided_session_id_is_used_and_echoed(self, adapter):
    +        """When X-Hermes-Session-Id is provided, it's passed to the agent and echoed in the response."""
    +        mock_result = {"final_response": "Continuing!", "messages": [], "api_calls": 1}
    +        mock_db = MagicMock()
    +        mock_db.get_messages_as_conversation.return_value = [
    +            {"role": "user", "content": "previous message"},
    +            {"role": "assistant", "content": "previous reply"},
    +        ]
    +        adapter._session_db = mock_db
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
    +                mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
    +
    +                resp = await cli.post(
    +                    "/v1/chat/completions",
    +                    headers={"X-Hermes-Session-Id": "my-session-123"},
    +                    json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Continue"}]},
    +                )
    +
    +            assert resp.status == 200
    +            assert resp.headers.get("X-Hermes-Session-Id") == "my-session-123"
    +            call_kwargs = mock_run.call_args.kwargs
    +            assert call_kwargs["session_id"] == "my-session-123"
    +
    +    @pytest.mark.asyncio
    +    async def test_provided_session_id_loads_history_from_db(self, adapter):
    +        """When X-Hermes-Session-Id is provided, history comes from SessionDB not request body."""
    +        mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
    +        db_history = [
    +            {"role": "user", "content": "stored message 1"},
    +            {"role": "assistant", "content": "stored reply 1"},
    +        ]
    +        mock_db = MagicMock()
    +        mock_db.get_messages_as_conversation.return_value = db_history
    +        adapter._session_db = mock_db
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
    +                mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
    +
    +                resp = await cli.post(
    +                    "/v1/chat/completions",
    +                    headers={"X-Hermes-Session-Id": "existing-session"},
    +                    # Request body has different history — should be ignored
    +                    json={
    +                        "model": "hermes-agent",
    +                        "messages": [
    +                            {"role": "user", "content": "old msg from client"},
    +                            {"role": "assistant", "content": "old reply from client"},
    +                            {"role": "user", "content": "new question"},
    +                        ],
    +                    },
    +                )
    +
    +            assert resp.status == 200
    +            call_kwargs = mock_run.call_args.kwargs
    +            # History must come from DB, not from the request body
    +            assert call_kwargs["conversation_history"] == db_history
    +            assert call_kwargs["user_message"] == "new question"
    +
    +    @pytest.mark.asyncio
    +    async def test_db_failure_falls_back_to_empty_history(self, adapter):
    +        """If SessionDB raises, history falls back to empty and request still succeeds."""
    +        mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
    +        # Simulate DB failure: _session_db is None and SessionDB() constructor raises
    +        adapter._session_db = None
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \
    +                 patch("hermes_state.SessionDB", side_effect=Exception("DB unavailable")):
    +                mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
    +
    +                resp = await cli.post(
    +                    "/v1/chat/completions",
    +                    headers={"X-Hermes-Session-Id": "some-session"},
    +                    json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]},
    +                )
    +
    +            assert resp.status == 200
    +            call_kwargs = mock_run.call_args.kwargs
    +            assert call_kwargs["conversation_history"] == []
    +            assert call_kwargs["session_id"] == "some-session"
    ```
- **#3439: feat(api_server): persist /v1/chat/completions sessions via X-Chat-ID header**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#4478`
- **#7127: fix(api-server): share one Docker container across all API conversations**. merged to main
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/api_server.py`, `tests/gateway/test_api_server.py`

## Fix status

- Merged to main in PR #4478 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-01`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
